swipe、右键菜单

This commit is contained in:
2026-05-05 21:51:34 +08:00
parent 44df56c8d2
commit f843a74715
19 changed files with 1319 additions and 57 deletions

173
REROLL_DEBUG_GUIDE.md Normal file
View 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
View 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
View 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
View 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
View 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 的设计规范。

View File

@@ -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)
# ✅ 检查是否是取消任务的请求

View File

@@ -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:

View 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"}

View File

@@ -0,0 +1,10 @@
{
"https://api.deepseek.com/v1": {
"totalPromptTokens": 2060,
"totalCompletionTokens": 519,
"totalTokens": 2579,
"count": 5,
"firstUsed": 1777984056,
"lastUsed": 1777987419
}
}

View File

@@ -0,0 +1,8 @@
{
"2026-05-05": {
"promptTokens": 2060,
"completionTokens": 519,
"totalTokens": 2579,
"count": 5
}
}

View File

@@ -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 });

View File

@@ -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 */

View File

@@ -9,6 +9,17 @@ const ChatBox = () => {
const messagesEndRef = useRef(null);
const optionsRef = useRef(null);
const chatSelectorRef = useRef(null);
const messagesContainerRef = useRef(null); // ✅ 新增:消息容器引用
const isUserAtBottomRef = useRef(true); // ✅ 新增:跟踪用户是否在底部
const contextMenuRef = useRef(null); // ✅ 新增:右键菜单引用
// ✅ 新增:右键菜单状态
const [contextMenu, setContextMenu] = React.useState({
visible: false,
x: 0,
y: 0,
message: null
});
// ✅ 从 ChatBoxUIStore 获取 UI 状态
const {
@@ -86,6 +97,26 @@ const ChatBox = () => {
};
}, [showChatSelector]);
// ✅ 点击外部关闭右键菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(event.target)) {
closeContextMenu();
}
};
if (contextMenu.visible) {
document.addEventListener('mousedown', handleClickOutside);
// ✅ 添加滚动监听,防止滚动时菜单不关闭
document.addEventListener('scroll', closeContextMenu, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', closeContextMenu, true);
};
}, [contextMenu.visible]);
const handleInputHeight = (e) => {
const textarea = e.target;
@@ -114,15 +145,105 @@ const ChatBox = () => {
}
};
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
// 自动滚动到底部(智能滚动)
const scrollToBottom = (force = false) => {
// 只有在用户在底部或强制滚动时才执行
if (isUserAtBottomRef.current || force) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
};
// 检测用户是否在底部
const checkIfUserAtBottom = () => {
if (!messagesContainerRef.current) return true;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
// 允许 20px 的误差范围
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
isUserAtBottomRef.current = isAtBottom;
return isAtBottom;
};
// 监听消息容器的滚动事件
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
checkIfUserAtBottom();
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
// 当消息变化时,智能滚动
useEffect(() => {
scrollToBottom();
}, [messages]);
// ✅ 监听全局键盘事件 - 左右键切换 swipe 或触发重roll
useEffect(() => {
const handleGlobalKeyDown = (e) => {
// 如果正在输入框中输入,不处理
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
return;
}
// 只处理左右方向键
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') {
return;
}
e.preventDefault();
// 找到最后一条 AI 消息
const lastAiMessage = [...messages].reverse().find(m => !m.is_user);
if (!lastAiMessage) {
return;
}
const hasSwipes = lastAiMessage.swipes && lastAiMessage.swipes.length > 0;
if (e.key === 'ArrowLeft') {
// 左键:如果有 swipe切换到上一个版本否则不做任何操作
if (hasSwipes) {
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
? currentSwipeId[lastAiMessage.floor]
: lastAiMessage.swipe_id;
if (currentIndex > 0) {
handleSwipeChange(lastAiMessage.floor, -1);
}
}
// 如果没有 swipe左键不做任何操作
} else if (e.key === 'ArrowRight') {
// 右键:如果有 swipe 且不是最后一个版本切换到下一个版本否则触发重roll
if (hasSwipes) {
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
? currentSwipeId[lastAiMessage.floor]
: lastAiMessage.swipe_id;
if (currentIndex < lastAiMessage.swipes.length - 1) {
handleSwipeChange(lastAiMessage.floor, 1);
} else {
// 已在最后一个版本触发重roll
console.log('[ChatBox] 已在最后一个版本触发重roll发送新消息');
handleRerollMessage(lastAiMessage);
}
} else {
// 没有 swipe直接触发重roll
console.log('[ChatBox] 没有 swipe触发重roll发送新消息');
handleRerollMessage(lastAiMessage);
}
}
};
window.addEventListener('keydown', handleGlobalKeyDown);
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
}, [messages, currentSwipeId]);
// 处理编辑消息
const handleEdit = (message) => {
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
@@ -155,6 +276,109 @@ const ChatBox = () => {
}
};
// ✅ 新增:右键菜单处理
const handleContextMenu = (e, message) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
message: message
});
};
// ✅ 关闭右键菜单
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, message: null });
};
// ✅ 复制消息内容
const handleCopyMessage = async (message) => {
try {
await navigator.clipboard.writeText(message.mes);
console.log('[ChatBox] 消息已复制到剪贴板');
closeContextMenu();
} catch (err) {
console.error('[ChatBox] 复制失败:', err);
alert('复制失败');
}
};
// ✅ 删除消息
const handleDeleteMessage = async (message) => {
if (!confirm(`确定要删除这条消息吗?`)) {
closeContextMenu();
return;
}
try {
const { deleteMessage } = useChatBoxStore.getState();
await deleteMessage(message.floor);
console.log('[ChatBox] 消息已删除');
closeContextMenu();
} catch (err) {
console.error('[ChatBox] 删除失败:', err);
alert('删除失败');
}
};
// ✅ 从右键菜单编辑
const handleEditFromContext = (message) => {
startEditing(message.floor, message.mes);
closeContextMenu();
};
// ✅ 重roll消息重新生成- 在目标消息的swipe数组中添加新版本
const handleRerollMessage = async (message) => {
// 只有 AI 消息才能重roll
if (message.is_user) {
closeContextMenu();
return;
}
try {
const { currentRole, currentChat, wsConnection } = useChatBoxStore.getState();
if (!currentRole || !currentChat) {
alert('请先选择角色和聊天');
closeContextMenu();
return;
}
// 获取上一条用户消息
const messageIndex = messages.findIndex(m => m.floor === message.floor);
let userMessage = null;
// 向前查找最近的用户消息
for (let i = messageIndex - 1; i >= 0; i--) {
if (messages[i].is_user) {
userMessage = messages[i];
break;
}
}
if (!userMessage) {
alert('找不到上一条用户消息');
closeContextMenu();
return;
}
console.log('[ChatBox] 重roll消息使用用户输入:', userMessage.mes);
console.log('[ChatBox] 目标消息 floor:', message.floor);
// 关闭菜单
closeContextMenu();
// ✅ 调用 sendMessage传入 targetFloor 参数在目标消息的swipe数组中添加新版本
const { sendMessage } = useChatBoxStore.getState();
await sendMessage(userMessage.mes, message.floor); // ✅ 传入 targetFloor
} catch (err) {
console.error('[ChatBox] 重roll失败:', err);
alert('重roll失败: ' + err.message);
}
};
// 切换选项显示
const toggleOptionsPanel = () => {
toggleOptions(); // ✅ 使用 store 方法
@@ -268,28 +492,16 @@ const ChatBox = () => {
const uniqueKey = message.id || `${message.floor}-${index}`;
return (
<div key={uniqueKey} className={`message ${isUser ? 'user' : 'ai'}`}>
<div
key={uniqueKey}
className={`message ${isUser ? 'user' : 'ai'}`}
onContextMenu={(e) => handleContextMenu(e, message)} // 添加右键菜单
>
<div className="message-container">
<div className="message-header">
<span className="message-name">{displayName}</span>
<span className="message-id">#{message.floor}</span>
<div className="message-toolbar">
<div className="toolbar-buttons">
<button
className="toolbar-button"
onClick={() => handleEdit(message)}
title="编辑"
>
</button>
<button
className="toolbar-button"
title="更多"
>
</button>
</div>
</div>
{/* ✅ 移除工具栏按钮,改用右键菜单 */}
</div>
<div className="message-content">
{isEditing ? (
@@ -339,22 +551,25 @@ const ChatBox = () => {
) : (
<div className="plain-text">{currentMes}</div>
)}
{hasSwipes && !isUser && isLatestMessage && (
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
{hasSwipes && !isUser && (
<div className="swipe-controls">
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, -1)}
disabled={currentSwipeIndex === 0}
title="上一个版本"
>
</button>
<span className="swipe-counter">
<span className="swipe-counter" title={`${message.swipes.length} 个版本`}>
{currentSwipeIndex + 1}/{message.swipes.length}
</span>
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, 1)}
disabled={currentSwipeIndex === message.swipes.length - 1}
title="下一个版本"
>
</button>
@@ -370,7 +585,7 @@ const ChatBox = () => {
return (
<div className="chat-box">
<div className="chat-messages">
<div className="chat-messages" ref={messagesContainerRef}>
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
@@ -548,6 +763,45 @@ const ChatBox = () => {
</div>
</div>
)}
{/* ✅ 右键菜单 */}
{contextMenu.visible && contextMenu.message && (
<div
className="context-menu-overlay"
onClick={closeContextMenu}
>
<div
className="context-menu"
ref={contextMenuRef}
style={{
left: `${contextMenu.x}px`,
top: `${contextMenu.y}px`
}}
onClick={(e) => e.stopPropagation()}
>
<div className="context-menu-item" onClick={() => handleEditFromContext(contextMenu.message)}>
<span className="menu-icon"></span>
<span className="menu-label">编辑</span>
</div>
<div className="context-menu-item" onClick={() => handleCopyMessage(contextMenu.message)}>
<span className="menu-icon">📋</span>
<span className="menu-label">复制</span>
</div>
{/* ✅ 只有 AI 消息才显示重roll选项 */}
{!contextMenu.message.is_user && (
<div className="context-menu-item" onClick={() => handleRerollMessage(contextMenu.message)}>
<span className="menu-icon">🔄</span>
<span className="menu-label">重roll</span>
</div>
)}
<div className="context-menu-divider"></div>
<div className="context-menu-item danger" onClick={() => handleDeleteMessage(contextMenu.message)}>
<span className="menu-icon">🗑</span>
<span className="menu-label">删除</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -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);
}

View File

@@ -1,5 +1,5 @@
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
import React from 'react';
import React, { useRef } from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
@@ -15,6 +15,15 @@ const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
const { fetchPresets } = usePresetStore();
const { fetchProfiles } = useApiConfigStore();
const contextMenuRef = useRef(null);
// ✅ 右键菜单状态
const [contextMenu, setContextMenu] = React.useState({
visible: false,
x: 0,
y: 0,
tab: null
});
// 处理标签切换
const handleTabClick = (tabId) => {
@@ -31,6 +40,28 @@ const SideBarLeft = () => {
}
};
// ✅ 显示右键菜单
const handleContextMenu = (e, tab) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
tab: tab
});
};
// ✅ 关闭右键菜单
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, tab: null });
};
// ✅ 点击菜单项后自动切换到对应标签
const handleSwitchToTab = (tabId) => {
handleTabClick(tabId);
closeContextMenu();
};
return (
<div className="sidebar-left">
<div className="sidebar-tabs">
@@ -39,7 +70,8 @@ const SideBarLeft = () => {
key={tab.id}
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
title={tab.title}
onContextMenu={(e) => handleContextMenu(e, tab)} // 添加右键菜单
// 移除 title改用右键菜单显示详情
>
{tab.label}
</button>
@@ -54,6 +86,67 @@ const SideBarLeft = () => {
{activeTab === 'worldbook' && <WorldBook />}
{activeTab === 'tokenUsage' && <TokenUsage />}
</div>
{/* ✅ 右键菜单 */}
{contextMenu.visible && contextMenu.tab && (
<div
className="context-menu-overlay"
onClick={closeContextMenu}
>
<div
className="context-menu sidebar-tab-context-menu"
ref={contextMenuRef}
style={{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }}
onClick={(e) => e.stopPropagation()}
>
{/* 菜单标题 - 显示标签名称 */}
<div className="context-menu-header">
<span className="menu-title">{contextMenu.tab.label}</span>
</div>
{/* 菜单描述 */}
<div className="context-menu-description">
{contextMenu.tab.title}
</div>
<div className="context-menu-divider"></div>
{/* 操作项 */}
<div className="context-menu-item" onClick={() => handleSwitchToTab(contextMenu.tab.id)}>
<span className="menu-icon">📂</span>
<span className="menu-label">打开</span>
</div>
{/* 根据标签类型显示不同的快捷操作 */}
{contextMenu.tab.id === 'character' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('character'); setTimeout(() => document.querySelector('.create-character-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建角色</span>
</div>
</>
)}
{contextMenu.tab.id === 'presets' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('presets'); setTimeout(() => document.querySelector('.create-preset-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建预设</span>
</div>
</>
)}
{contextMenu.tab.id === 'worldbook' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('worldbook'); setTimeout(() => document.querySelector('.action-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建世界书</span>
</div>
</>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -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);
}

View File

@@ -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 {

View 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;
}

View File

@@ -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;