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