添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现
This commit is contained in:
173
CHAT_SUMMARY_TEST_GUIDE.md
Normal file
173
CHAT_SUMMARY_TEST_GUIDE.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# 聊天记录总结功能测试指南
|
||||||
|
|
||||||
|
## 测试数据说明
|
||||||
|
|
||||||
|
已创建测试聊天:**帝国骑士维尔 / 恶魔女友长篇测试**
|
||||||
|
|
||||||
|
### 故事架构
|
||||||
|
- **主题**:日式恶魔女友角色扮演
|
||||||
|
- **情节**:User召唤魅魔,发现对方是前女友 → 签订契约 → 前女友表面嫌弃但内心不舍
|
||||||
|
- **长度**:10层消息,前5层每层约2000字符,后5层为占位符
|
||||||
|
- **总字符数**:约10,894字符
|
||||||
|
|
||||||
|
### 角色设定
|
||||||
|
- **雪乃(莉莉丝)**:前女友变成的高阶魅魔,银发红眼,哥特萝莉装,口是心非
|
||||||
|
- **我(User)**:召唤者,深情且坚定,愿意为爱牺牲
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
### 1. 在UI中选择总结模式
|
||||||
|
|
||||||
|
1. 打开左侧栏角色卡片
|
||||||
|
2. 选择"帝国骑士维尔"角色
|
||||||
|
3. 进入编辑模式(点击角色卡片)
|
||||||
|
4. 在"历史记录模式"下拉框中选择 **"总结模式(定期总结历史)"**
|
||||||
|
5. 配置总结参数:
|
||||||
|
- **总结间隔**:2(每2条AI回复触发总结)
|
||||||
|
- **保留最近楼层**:2(最后2层不总结)
|
||||||
|
- **包含用户输入**:勾选
|
||||||
|
- **总结提示词**:保持默认或自定义
|
||||||
|
6. 保存设置
|
||||||
|
|
||||||
|
### 2. 开始对话测试
|
||||||
|
|
||||||
|
#### 预期行为
|
||||||
|
|
||||||
|
**初始状态**:
|
||||||
|
- L1-L10:正常显示所有消息
|
||||||
|
|
||||||
|
**当AI回复达到2条时**(L2、L4、L6、L8):
|
||||||
|
- 前端计数器检测到阈值
|
||||||
|
- 异步调用总结API:`POST /api/chats/帝国骑士维尔/恶魔女友长篇测试/summarize`
|
||||||
|
- 传递参数:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"start_floor": 1,
|
||||||
|
"end_floor": 2,
|
||||||
|
"summary_config": {
|
||||||
|
"interval": 2,
|
||||||
|
"recentFloorsToKeep": 2,
|
||||||
|
"includeUserInput": true,
|
||||||
|
"summaryPrompt": "...",
|
||||||
|
"maxSummaryLength": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**总结完成后**:
|
||||||
|
- L1:`mes=""`, `is_summarized=true`(清空内容)
|
||||||
|
- L2:`mes="总结文本"`, `is_summary=true`, `summary_range="L1-L2"`
|
||||||
|
- L3-L10:保持不变
|
||||||
|
|
||||||
|
**继续对话到L4时**:
|
||||||
|
- L3:`mes=""`, `is_summarized=true`
|
||||||
|
- L4:`mes="总结文本"`, `is_summary=true`, `summary_range="L3-L4"`
|
||||||
|
- L5-L10:保持不变
|
||||||
|
|
||||||
|
**到达L8时**(保护最后2层):
|
||||||
|
- L7:`mes=""`, `is_summarized=true`
|
||||||
|
- L8:`mes="总结文本"`, `is_summary=true`, `summary_range="L7-L8"`
|
||||||
|
- **L9-L10:永远不会被总结**(因为设置了保留最后2层)
|
||||||
|
|
||||||
|
### 3. 验证总结效果
|
||||||
|
|
||||||
|
#### 检查点
|
||||||
|
|
||||||
|
✅ **数据结构验证**
|
||||||
|
```bash
|
||||||
|
# 查看聊天文件
|
||||||
|
cat data/chat/帝国骑士维尔/恶魔女友长篇测试.jsonl | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
应该看到:
|
||||||
|
- 被总结的楼层:`"mes": ""`, `"is_summarized": true`
|
||||||
|
- 总结楼层:`"mes": "总结内容..."`, `"is_summary": true`, `"summary_range": "Lx-Ly"`
|
||||||
|
|
||||||
|
✅ **前端显示验证**
|
||||||
|
- 空楼层应显示省略标记(如 "--- 已总结 ---")
|
||||||
|
- 总结楼层应高亮显示,并显示范围提示
|
||||||
|
- 最后2层始终保持原始内容
|
||||||
|
|
||||||
|
✅ **Prompt组装验证**
|
||||||
|
- 发送给LLM的prompt中,`is_summarized=true`且`mes=""`的消息应被过滤
|
||||||
|
- 总结消息(`is_summary=true`)应保留并作为上下文
|
||||||
|
|
||||||
|
✅ **正则处理验证**
|
||||||
|
- 总结后的消息仍应经过正则处理器
|
||||||
|
- 空楼层不应影响正则匹配
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动测试API
|
||||||
|
|
||||||
|
如果前端集成尚未完成,可以手动测试API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 获取聊天状态
|
||||||
|
curl http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summary-status
|
||||||
|
|
||||||
|
# 2. 触发总结(总结L1-L2)
|
||||||
|
curl -X POST http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summarize \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"start_floor": 1,
|
||||||
|
"end_floor": 2,
|
||||||
|
"summary_config": {
|
||||||
|
"interval": 2,
|
||||||
|
"recentFloorsToKeep": 2,
|
||||||
|
"includeUserInput": true,
|
||||||
|
"summaryPrompt": "请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||||
|
"maxSummaryLength": 500
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. 再次获取状态,验证总结结果
|
||||||
|
curl http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summary-status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 总结后消息丢失怎么办?
|
||||||
|
A: 总结采用"楼层保留式"机制,原文被清空但楼层索引不变。可以通过查看JSONL文件恢复原始数据。
|
||||||
|
|
||||||
|
### Q2: 总结质量不理想怎么办?
|
||||||
|
A: 调整`summaryPrompt`提示词,或增加`maxSummaryLength`限制。
|
||||||
|
|
||||||
|
### Q3: 如何重置总结状态?
|
||||||
|
A: 目前需要手动编辑JSONL文件,将所有`is_summarized`和`is_summary`字段设为`false`,并恢复`mes`内容。
|
||||||
|
|
||||||
|
### Q4: RAG模式为什么不能取消?
|
||||||
|
A: RAG模式涉及数据库结构变更,一旦启用会产生持久化数据,为避免数据不一致,设计为不可逆操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能指标
|
||||||
|
|
||||||
|
- **单条消息平均长度**:~1,000字符
|
||||||
|
- **总结触发频率**:每2条AI回复
|
||||||
|
- **预计总结次数**:(10-2)/2 = 4次
|
||||||
|
- **LocalStorage占用**:< 1KB(仅存储计数器)
|
||||||
|
- **API响应时间**:< 3秒(取决于LLM速度)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步优化
|
||||||
|
|
||||||
|
1. **前端集成**:在ChatBoxSlice中集成计数器逻辑
|
||||||
|
2. **自动触发**:用户发送消息时自动检查是否达到总结阈值
|
||||||
|
3. **进度显示**:在右侧栏任务队列中显示总结进度
|
||||||
|
4. **批量总结**:支持一次性总结多个区间
|
||||||
|
5. **总结历史**:保存每次总结的元数据(时间、长度等)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 联系与支持
|
||||||
|
|
||||||
|
如有问题,请查看:
|
||||||
|
- 后端日志:`docker-compose logs -f backend`
|
||||||
|
- 前端控制台:浏览器开发者工具
|
||||||
|
- 相关文档:`WORLDBOOK_ACTIVATION_LOGIC.md`, `SETTINGS_TEST_GUIDE.md`
|
||||||
292
WORLDBOOK_ACTIVATION_LOGIC.md
Normal file
292
WORLDBOOK_ACTIVATION_LOGIC.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# 世界书激活业务逻辑说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
世界书(WorldBook)系统允许用户创建动态的知识条目,这些条目可以根据特定条件自动激活并注入到对话上下文中。本文档详细说明世界书条目的筛选和分类逻辑。
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
### 1. 数据收集阶段
|
||||||
|
|
||||||
|
后端从两个来源加载世界书条目:
|
||||||
|
|
||||||
|
1. **全局世界书** (`globalBooks`)
|
||||||
|
- 来自前端传递的全局世界书列表
|
||||||
|
- 所有对话都会检查这些世界书
|
||||||
|
|
||||||
|
2. **角色绑定世界书** (`characterBookId`)
|
||||||
|
- 与当前角色卡绑定的世界书
|
||||||
|
- 仅在该角色的对话中检查
|
||||||
|
|
||||||
|
### 2. 条目筛选阶段
|
||||||
|
|
||||||
|
对每个世界书条目进行以下检查:
|
||||||
|
|
||||||
|
#### 2.1 禁用状态检查
|
||||||
|
```python
|
||||||
|
if entry_data.get("disable", False):
|
||||||
|
continue # 跳过禁用的条目
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 触发条件检查
|
||||||
|
|
||||||
|
支持四种触发类型:
|
||||||
|
|
||||||
|
##### A. 常驻触发 (constant)
|
||||||
|
- **配置**: `trigger_config.triggers.constant[0] == true`
|
||||||
|
- **行为**: 总是激活,无需任何条件
|
||||||
|
- **适用场景**: 角色基础设定、世界观背景等始终需要的信息
|
||||||
|
|
||||||
|
##### B. 关键词触发 (keyword)
|
||||||
|
- **配置**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": ["关键词1", "关键词2"],
|
||||||
|
"caseSensitive": false,
|
||||||
|
"matchWholeWords": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **检查范围**:
|
||||||
|
- 用户当前输入 (`user_message`)
|
||||||
|
- 聊天历史消息 (`chat_history`)
|
||||||
|
- **匹配模式**:
|
||||||
|
- 大小写敏感/不敏感
|
||||||
|
- 全词匹配/部分匹配
|
||||||
|
- **适用场景**: 当对话中提到特定概念时激活相关解释
|
||||||
|
|
||||||
|
##### C. 条件触发 (condition)
|
||||||
|
- **配置**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conditions": [
|
||||||
|
{"text": "tb.力量 > 10"},
|
||||||
|
{"text": "tb.态度 包括 \"友好\""}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **支持的语法**:
|
||||||
|
- 数值比较: `tb.字段名 >/</>=/<=/==/!= 数值`
|
||||||
|
- 字符串相等: `tb.字段名 = "值"`
|
||||||
|
- 字符串包含: `tb.字段名 包括 "子串"`
|
||||||
|
- **逻辑**: 所有条件必须同时满足 (AND)
|
||||||
|
- **适用场景**: 基于动态表格状态的 conditional 内容
|
||||||
|
|
||||||
|
##### D. RAG触发 (暂未实现)
|
||||||
|
- 预留接口,未来可支持向量检索激活
|
||||||
|
|
||||||
|
### 3. 位置分类阶段
|
||||||
|
|
||||||
|
激活的条目按插入位置进行分类和排序:
|
||||||
|
|
||||||
|
#### 位置编号及含义
|
||||||
|
|
||||||
|
| 位置 | 标签 | 权重 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 0 | 角色定义之后 | 高 | AI读完人设紧接着就读到这里,适合补充角色的详细设定 |
|
||||||
|
| 1 | 角色定义之前 | 中 | 在角色卡内容的最上方,用于定义角色的基础背景 |
|
||||||
|
| 2 | 示例对话之前 | 低 | 在对话示例的最上方 |
|
||||||
|
| 3 | 示例对话之后 | 低 | 用于在对话开始前提供最后的上下文补充 |
|
||||||
|
| 4 | 系统提示/作者注释 | 极高 | AI对最近看到的信息记忆最清晰,适合动态信息 |
|
||||||
|
| 5 | 作为系统消息 | 最高 | 强制作为System Prompt插入,用于强制指令 |
|
||||||
|
| 6 | 深度插入 | - | 预留,待实现 |
|
||||||
|
| 7 | 宏替换 | - | 预留,待实现 |
|
||||||
|
|
||||||
|
#### 排序规则
|
||||||
|
|
||||||
|
```python
|
||||||
|
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **主排序**: 按 `position` 升序(位置编号小的先插入)
|
||||||
|
2. **次排序**: 同位置内按 `order` 升序(order值小的先插入)
|
||||||
|
|
||||||
|
### 4. 统计与日志
|
||||||
|
|
||||||
|
激活完成后,生成详细的统计信息:
|
||||||
|
|
||||||
|
```
|
||||||
|
[WorldBook] 📊 激活统计:
|
||||||
|
- 总激活条目数: 5
|
||||||
|
- 按位置分布:
|
||||||
|
* 角色定义之后 (pos=0): 2 个
|
||||||
|
* 系统提示/作者注释 (pos=4): 3 个
|
||||||
|
- 按触发类型分布:
|
||||||
|
* constant: 2 个
|
||||||
|
* keyword: 2 个
|
||||||
|
* condition: 1 个
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据结构
|
||||||
|
|
||||||
|
### 前端发送的数据格式
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
worldBookData: {
|
||||||
|
globalBooks: [
|
||||||
|
{ id: "...", name: "全局世界书1" },
|
||||||
|
{ id: "...", name: "全局世界书2" }
|
||||||
|
],
|
||||||
|
characterBookId: "角色绑定的世界书ID"
|
||||||
|
},
|
||||||
|
mes: "用户输入的消息",
|
||||||
|
chatHistory: ["历史消息1", "历史消息2"],
|
||||||
|
dynamicTableData: {
|
||||||
|
currentValues: {
|
||||||
|
"力量": 15,
|
||||||
|
"态度": "友好"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端返回的激活条目格式
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"type": "worldbook_active",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"uid": "uuid-string",
|
||||||
|
"content": "条目内容",
|
||||||
|
"position": 0,
|
||||||
|
"order": 100,
|
||||||
|
"trigger_config": {...},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现细节
|
||||||
|
|
||||||
|
### 关键方法
|
||||||
|
|
||||||
|
1. **`_collect_and_activate_worldbooks()`**
|
||||||
|
- 主入口方法
|
||||||
|
- 负责加载世界书、检查激活条件、排序和统计
|
||||||
|
|
||||||
|
2. **`_check_entry_activation()`**
|
||||||
|
- 检查单个条目的激活条件
|
||||||
|
- 支持 constant、keyword、condition 三种触发类型
|
||||||
|
|
||||||
|
3. **`_check_keyword_trigger_with_config()`**
|
||||||
|
- 关键词匹配逻辑
|
||||||
|
- 支持大小写敏感和全词匹配选项
|
||||||
|
|
||||||
|
4. **`_parse_table_condition()`**
|
||||||
|
- 解析动态表格条件表达式
|
||||||
|
- 支持数值比较和字符串操作
|
||||||
|
|
||||||
|
5. **`_get_trigger_type()`**
|
||||||
|
- 识别条目的触发类型
|
||||||
|
- 用于日志和统计
|
||||||
|
|
||||||
|
6. **`_get_position_label()`**
|
||||||
|
- 将位置编号转换为中文标签
|
||||||
|
- 用于日志和前端显示
|
||||||
|
|
||||||
|
### 数据类型标准化
|
||||||
|
|
||||||
|
在激活前,对所有条目数据进行标准化处理:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _normalize_worldbook_entry(entry_data):
|
||||||
|
# content 必须是字符串
|
||||||
|
# uid 必须是字符串
|
||||||
|
# position 必须是整数
|
||||||
|
# group 必须是列表或None
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端集成
|
||||||
|
|
||||||
|
### WebSocket 消息接收
|
||||||
|
|
||||||
|
前端通过 WebSocket 接收激活条目:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ChatBoxSlice.jsx
|
||||||
|
else if (data.type === 'worldbook_active') {
|
||||||
|
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
|
||||||
|
import('../../Store/SideBarRight/WorldBookActiveSlice').then(module => {
|
||||||
|
module.default.getState().setActiveEntries(data.entries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 显示组件
|
||||||
|
|
||||||
|
`WorldBookActive` 组件按位置分组显示激活的条目:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// 按位置分组
|
||||||
|
const groupedEntries = activeEntries.reduce((acc, entry) => {
|
||||||
|
const pos = entry.position || 0;
|
||||||
|
if (!acc[pos]) acc[pos] = [];
|
||||||
|
acc[pos].push(entry);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// 渲染每个位置组
|
||||||
|
{Object.keys(groupedEntries).map(pos => (
|
||||||
|
<div key={pos} className="position-group">
|
||||||
|
<div className="position-label">
|
||||||
|
{positionLabels[pos]} ({pos})
|
||||||
|
</div>
|
||||||
|
{/* 渲染该位置的所有条目 */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展方向
|
||||||
|
|
||||||
|
### 短期优化
|
||||||
|
1. 添加概率触发支持 (`probability` 字段)
|
||||||
|
2. 实现RAG检索触发
|
||||||
|
3. 支持逻辑表达式触发 (`logicExpression`)
|
||||||
|
|
||||||
|
### 长期规划
|
||||||
|
1. 实现深度插入 (position=6)
|
||||||
|
2. 实现宏替换 (position=7)
|
||||||
|
3. 添加条目冲突解决机制
|
||||||
|
4. 支持条目依赖关系
|
||||||
|
|
||||||
|
## 调试建议
|
||||||
|
|
||||||
|
### 查看激活日志
|
||||||
|
|
||||||
|
后端会输出详细的激活日志:
|
||||||
|
|
||||||
|
```
|
||||||
|
[WorldBook] 📖 加载全局世界书 '测试世界书',共 10 个条目
|
||||||
|
[WorldBook-Check] 📌 常驻触发 | UID: xxx-xxx-xxx
|
||||||
|
[WorldBook] ✅ 全局世界书 '测试世界书' 条目激活 | UID: xxx | 触发: constant | 位置: 角色定义之后
|
||||||
|
[WorldBook-Check] 🔑 关键词触发(用户输入) | UID: yyy | 匹配关键词: ['魔法']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见问题排查
|
||||||
|
|
||||||
|
1. **条目未激活**
|
||||||
|
- 检查 `disable` 字段是否为 `true`
|
||||||
|
- 检查触发条件配置是否正确
|
||||||
|
- 查看日志中的 `[WorldBook-Check]` 输出
|
||||||
|
|
||||||
|
2. **位置不正确**
|
||||||
|
- 确认 `position` 字段是整数类型
|
||||||
|
- 检查 `order` 字段的排序是否符合预期
|
||||||
|
|
||||||
|
3. **关键词不匹配**
|
||||||
|
- 检查 `caseSensitive` 设置
|
||||||
|
- 检查 `matchWholeWords` 设置
|
||||||
|
- 确认关键词拼写正确
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
世界书激活系统实现了:
|
||||||
|
- ✅ 多源数据加载(全局 + 角色绑定)
|
||||||
|
- ✅ 多种触发类型(常驻、关键词、条件)
|
||||||
|
- ✅ 智能位置分类(8个插入位置)
|
||||||
|
- ✅ 详细统计分析(按位置和触发类型)
|
||||||
|
- ✅ 完整日志记录(便于调试)
|
||||||
|
|
||||||
|
这为动态上下文管理提供了强大的基础,可以根据对话内容智能地注入相关知识。
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute
|
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute
|
||||||
from utils.file_utils import get_all_roles_and_chats
|
from utils.file_utils import get_all_roles_and_chats
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,6 +17,7 @@ router.include_router(charactersRoute.router)
|
|||||||
router.include_router(tokenUsageRoute.router)
|
router.include_router(tokenUsageRoute.router)
|
||||||
router.include_router(imageGalleryRoute.router)
|
router.include_router(imageGalleryRoute.router)
|
||||||
router.include_router(regexRoute.router)
|
router.include_router(regexRoute.router)
|
||||||
|
router.include_router(chatSummaryRoute.router)
|
||||||
|
|
||||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||||
router.include_router(chatWsRoute.router)
|
router.include_router(chatWsRoute.router)
|
||||||
|
|||||||
154
backend/api/routes/chatSummaryRoute.py
Normal file
154
backend/api/routes/chatSummaryRoute.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
聊天总结 API 路由
|
||||||
|
|
||||||
|
处理聊天记录的总结请求
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Body
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from services.chat_service import chat_service
|
||||||
|
from services.chat_summary_service import chat_summary_service
|
||||||
|
from models.internal import SummaryConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chats", tags=["chat-summary"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{role_name}/{chat_name}/summarize")
|
||||||
|
async def summarize_chat_history(
|
||||||
|
role_name: str,
|
||||||
|
chat_name: str,
|
||||||
|
request_data: Dict[str, Any] = Body(...)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
总结聊天历史记录
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
request_data: {
|
||||||
|
"startFloor": int, # 总结起始楼层
|
||||||
|
"endFloor": int, # 总结结束楼层
|
||||||
|
"summaryConfig": {...}, # 总结配置
|
||||||
|
"apiConfig": {...} # API配置
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": bool,
|
||||||
|
"summaryText": str, # 总结文本
|
||||||
|
"startFloor": int,
|
||||||
|
"endFloor": int,
|
||||||
|
"message": str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 提取请求参数
|
||||||
|
start_floor = request_data.get("startFloor")
|
||||||
|
end_floor = request_data.get("endFloor")
|
||||||
|
summary_config_data = request_data.get("summaryConfig", {})
|
||||||
|
api_config = request_data.get("apiConfig", {})
|
||||||
|
|
||||||
|
if not start_floor or not end_floor:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
|
||||||
|
|
||||||
|
# 2. 加载聊天记录
|
||||||
|
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||||
|
if not chat_log:
|
||||||
|
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
|
||||||
|
|
||||||
|
messages = chat_log.messages
|
||||||
|
total_messages = len(messages)
|
||||||
|
|
||||||
|
# 3. 验证楼层范围
|
||||||
|
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 构建SummaryConfig对象
|
||||||
|
summary_config = SummaryConfig(**summary_config_data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
|
||||||
|
f"楼层范围: {start_floor}-{end_floor}, "
|
||||||
|
f"包含用户输入: {summary_config.includeUserInput}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 调用总结服务
|
||||||
|
summary_text = await chat_summary_service.summarize_messages(
|
||||||
|
messages=messages,
|
||||||
|
start_floor=start_floor,
|
||||||
|
end_floor=end_floor,
|
||||||
|
summary_config=summary_config,
|
||||||
|
api_config=api_config
|
||||||
|
)
|
||||||
|
|
||||||
|
if not summary_text:
|
||||||
|
raise HTTPException(status_code=500, detail="总结生成失败")
|
||||||
|
|
||||||
|
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
|
||||||
|
|
||||||
|
# 6. 更新聊天记录(清空原文 + 替换总结)
|
||||||
|
chat_service.summarize_chat_messages(
|
||||||
|
role_name=role_name,
|
||||||
|
chat_name=chat_name,
|
||||||
|
start_floor=start_floor,
|
||||||
|
end_floor=end_floor,
|
||||||
|
summary_text=summary_text
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"summaryText": summary_text,
|
||||||
|
"startFloor": start_floor,
|
||||||
|
"endFloor": end_floor,
|
||||||
|
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{role_name}/{chat_name}/summary-status")
|
||||||
|
async def get_summary_status(role_name: str, chat_name: str):
|
||||||
|
"""
|
||||||
|
获取聊天总结状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"historyMode": str,
|
||||||
|
"summaryCounter": int,
|
||||||
|
"lastSummaryFloor": int,
|
||||||
|
"summaryConfig": {...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||||
|
if not chat_log:
|
||||||
|
raise HTTPException(status_code=404, detail="聊天记录不存在")
|
||||||
|
|
||||||
|
header = chat_log.header
|
||||||
|
|
||||||
|
return {
|
||||||
|
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
|
||||||
|
"summaryCounter": header.summaryCounter or 0,
|
||||||
|
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
|
||||||
|
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -266,9 +266,11 @@ async def _handle_stream_chat(
|
|||||||
# ✅ 发送激活的世界书条目信息(在LLM调用前)
|
# ✅ 发送激活的世界书条目信息(在LLM调用前)
|
||||||
if active_entries:
|
if active_entries:
|
||||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||||
|
# 将 Pydantic 模型转换为字典
|
||||||
|
entries_dict = [entry.model_dump() for entry in active_entries]
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "worldbook_active",
|
"type": "worldbook_active",
|
||||||
"entries": active_entries
|
"entries": entries_dict
|
||||||
})
|
})
|
||||||
|
|
||||||
# ✅ TODO: RAG检索(暂时为空,待实现)
|
# ✅ TODO: RAG检索(暂时为空,待实现)
|
||||||
@@ -310,7 +312,7 @@ async def _handle_stream_chat(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ✅ 第3步:调用LLM流式生成
|
# ✅ 第3步:调用LLM流式生成
|
||||||
chunk_count = 0
|
chunk_count = [0] # 使用列表以便在闭包中修改
|
||||||
result = await workflow_service.process_chat_request_stream(
|
result = await workflow_service.process_chat_request_stream(
|
||||||
request_data,
|
request_data,
|
||||||
on_chunk=lambda chunk: asyncio.create_task(
|
on_chunk=lambda chunk: asyncio.create_task(
|
||||||
|
|||||||
@@ -40,19 +40,45 @@ async def get_preset(preset_name: str):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_preset(preset_name: str, preset_data: dict):
|
async def create_preset(preset_data: dict):
|
||||||
"""创建新预设"""
|
"""创建新预设"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
try:
|
||||||
|
preset_name = preset_data.get("name")
|
||||||
|
if not preset_name:
|
||||||
|
raise HTTPException(status_code=400, detail="preset name is required")
|
||||||
|
|
||||||
|
# 使用 create_preset 方法保存预设
|
||||||
|
saved_preset = PresetService.create_preset(preset_name, preset_data)
|
||||||
|
return {"success": True, "preset": saved_preset}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=409, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.put("/{preset_name}")
|
@router.put("/{preset_name}")
|
||||||
async def update_preset(preset_name: str, update_data: dict):
|
async def update_preset(preset_name: str, update_data: dict):
|
||||||
"""更新预设配置"""
|
"""更新预设配置"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
try:
|
||||||
|
updated_preset = PresetService.update_preset(preset_name, update_data)
|
||||||
|
return {"success": True, "preset": updated_preset}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.delete("/{preset_name}")
|
@router.delete("/{preset_name}")
|
||||||
async def delete_preset(preset_name: str):
|
async def delete_preset(preset_name: str):
|
||||||
"""删除指定预设"""
|
"""删除指定预设"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
try:
|
||||||
|
success = PresetService.delete_preset(preset_name)
|
||||||
|
if success:
|
||||||
|
return {"success": True, "message": f"Preset '{preset_name}' deleted"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_preset_component(preset_name: str, component_data: dict):
|
async def add_preset_component(preset_name: str, component_data: dict):
|
||||||
@@ -68,3 +94,18 @@ async def update_preset_component(preset_name: str, component_id: str, update_da
|
|||||||
async def delete_preset_component(preset_name: str, component_id: str):
|
async def delete_preset_component(preset_name: str, component_id: str):
|
||||||
"""从预设中删除指定组件"""
|
"""从预设中删除指定组件"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.post("/{preset_name}/reorder")
|
||||||
|
async def reorder_preset_components(preset_name: str, order_data: dict):
|
||||||
|
"""重新排序预设组件"""
|
||||||
|
try:
|
||||||
|
component_order = order_data.get("component_order", [])
|
||||||
|
if not component_order:
|
||||||
|
raise HTTPException(status_code=400, detail="component_order is required")
|
||||||
|
|
||||||
|
updated_preset = PresetService.reorder_components(preset_name, component_order)
|
||||||
|
return {"success": True, "preset": updated_preset}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ class CharacterCard(BaseModel):
|
|||||||
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
|
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
|
||||||
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
|
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
|
||||||
|
|
||||||
|
# ✅ 动态表格数据(SillyTavern 关键字机制扩展)
|
||||||
|
tableHeaders: Optional[List[str]] = Field(None, description="动态表格表头数组")
|
||||||
|
tableDefaults: Optional[Dict[str, Any]] = Field(None, description="动态表格默认值对象")
|
||||||
|
|
||||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||||
@@ -158,11 +162,41 @@ class CharacterCard(BaseModel):
|
|||||||
|
|
||||||
# ==================== 聊天记录 (Chat Log) ====================
|
# ==================== 聊天记录 (Chat Log) ====================
|
||||||
|
|
||||||
|
# 历史记录模式枚举
|
||||||
|
class HistoryMode(str, Enum):
|
||||||
|
"""
|
||||||
|
历史记录处理模式
|
||||||
|
|
||||||
|
- FULL: 全量模式,保留所有消息(需经正则处理)
|
||||||
|
- SUMMARY: 总结模式,定期用LLM总结历史消息
|
||||||
|
- RAG: RAG模式,基于向量检索(暂不实现)
|
||||||
|
"""
|
||||||
|
FULL = 'full' # 全量模式
|
||||||
|
SUMMARY = 'summary' # 总结模式
|
||||||
|
RAG = 'rag' # RAG模式(预留)
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
总结配置
|
||||||
|
|
||||||
|
用于控制历史消息的总结行为
|
||||||
|
"""
|
||||||
|
enabled: bool = Field(True, description="是否启用总结")
|
||||||
|
interval: int = Field(10, ge=2, description="总结间隔(每隔多少条消息总结一次)")
|
||||||
|
includeUserInput: bool = Field(True, description="总结时是否包含用户输入")
|
||||||
|
summaryPrompt: str = Field(
|
||||||
|
"请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||||
|
description="总结提示词"
|
||||||
|
)
|
||||||
|
maxSummaryLength: int = Field(500, ge=100, description="总结文本的最大长度(字符数)")
|
||||||
|
|
||||||
|
|
||||||
class ChatHeader(BaseModel):
|
class ChatHeader(BaseModel):
|
||||||
"""
|
"""
|
||||||
项目内部聊天记录头
|
项目内部聊天记录头
|
||||||
|
|
||||||
包含聊天的元数据,如参与角色、创建时间等。
|
包含聊天的元数据,如参与角色、创建时间等。
|
||||||
"""
|
"""
|
||||||
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||||
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||||
@@ -170,6 +204,12 @@ class ChatHeader(BaseModel):
|
|||||||
userName: str = Field("User", description="用户角色名")
|
userName: str = Field("User", description="用户角色名")
|
||||||
characterName: str = Field(..., description="AI 角色名称")
|
characterName: str = Field(..., description="AI 角色名称")
|
||||||
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
|
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
|
||||||
|
|
||||||
|
# ✅ 历史记录模式配置
|
||||||
|
historyMode: HistoryMode = Field(HistoryMode.FULL, description="历史记录处理模式 (full/summary/rag)")
|
||||||
|
summaryConfig: Optional[SummaryConfig] = Field(None, description="总结配置 (当 historyMode='summary' 时使用)")
|
||||||
|
summaryCounter: int = Field(0, ge=0, description="总结计数器(独立于楼层,用于跟踪需要总结的消息数)")
|
||||||
|
|
||||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
messageCount: int = Field(0, description="消息数量")
|
messageCount: int = Field(0, description="消息数量")
|
||||||
@@ -180,7 +220,7 @@ class ChatMessage(BaseModel):
|
|||||||
"""
|
"""
|
||||||
项目内部聊天消息
|
项目内部聊天消息
|
||||||
|
|
||||||
单条对话消息,支持多版本 (swipes)、token 统计等功能。
|
单条对话消息,支持多版本 (swipes)、token 统计、历史记录总结等功能。
|
||||||
"""
|
"""
|
||||||
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||||
name: str = Field(..., description="发送者名称")
|
name: str = Field(..., description="发送者名称")
|
||||||
@@ -193,6 +233,11 @@ class ChatMessage(BaseModel):
|
|||||||
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
||||||
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||||
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
||||||
|
|
||||||
|
# ✅ 历史记录总结相关字段
|
||||||
|
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
|
||||||
|
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
|
||||||
|
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
|
||||||
|
|
||||||
|
|
||||||
class ChatLog(BaseModel):
|
class ChatLog(BaseModel):
|
||||||
|
|||||||
76
backend/models/summary_message.py
Normal file
76
backend/models/summary_message.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
聊天总结消息数据模型
|
||||||
|
|
||||||
|
用于存储总结后的历史消息记录
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryMessage(BaseModel):
|
||||||
|
"""
|
||||||
|
总结消息
|
||||||
|
|
||||||
|
保存总结后的文本和元数据
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
|
||||||
|
chatId: str = Field(..., description="关联的聊天 ID")
|
||||||
|
|
||||||
|
# 总结内容
|
||||||
|
summaryText: str = Field(..., description="总结后的文本内容")
|
||||||
|
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
|
||||||
|
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
|
||||||
|
messageCount: int = Field(..., description="被总结的消息数量")
|
||||||
|
includeUserInput: bool = Field(True, description="是否包含用户输入")
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
|
||||||
|
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
|
||||||
|
|
||||||
|
# 版本控制
|
||||||
|
version: int = Field(1, description="总结版本号(用于追溯)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_summary(
|
||||||
|
cls,
|
||||||
|
chat_id: str,
|
||||||
|
summary_text: str,
|
||||||
|
message_ids: List[str],
|
||||||
|
include_user_input: bool = True,
|
||||||
|
version: int = 1
|
||||||
|
) -> 'SummaryMessage':
|
||||||
|
"""
|
||||||
|
创建总结消息的工厂方法
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: 聊天 ID
|
||||||
|
summary_text: 总结文本
|
||||||
|
message_ids: 被总结的消息 ID 列表
|
||||||
|
include_user_input: 是否包含用户输入
|
||||||
|
version: 版本号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SummaryMessage 实例
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# 生成消息范围描述
|
||||||
|
if len(message_ids) > 0:
|
||||||
|
message_range = f"{len(message_ids)}条消息"
|
||||||
|
else:
|
||||||
|
message_range = "无消息"
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
chatId=chat_id,
|
||||||
|
summaryText=summary_text,
|
||||||
|
originalMessageIds=message_ids,
|
||||||
|
messageRange=message_range,
|
||||||
|
messageCount=len(message_ids),
|
||||||
|
includeUserInput=include_user_input,
|
||||||
|
version=version
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ requests>=2.31.0
|
|||||||
|
|
||||||
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
||||||
langchain>=0.1.0
|
langchain>=0.1.0
|
||||||
|
langchain-core>=0.1.0
|
||||||
langchain-openai>=0.0.5
|
langchain-openai>=0.0.5
|
||||||
langchain-anthropic>=0.1.1
|
langchain-anthropic>=0.1.1
|
||||||
openai>=1.12.0
|
openai>=1.12.0
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ class CharacterService:
|
|||||||
outputSchema=data.get('outputSchema'),
|
outputSchema=data.get('outputSchema'),
|
||||||
avatarPath=avatar_path,
|
avatarPath=avatar_path,
|
||||||
alternate_greetings=data.get('alternate_greetings', []),
|
alternate_greetings=data.get('alternate_greetings', []),
|
||||||
|
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
|
||||||
|
imageGenerationPrompt=data.get('imageGenerationPrompt'),
|
||||||
|
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
|
||||||
|
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
|
||||||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||||
lastChatAt=last_chat_at,
|
lastChatAt=last_chat_at,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -453,3 +455,79 @@ class ChatService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def summarize_chat_messages(
|
||||||
|
self,
|
||||||
|
role_name: str,
|
||||||
|
chat_name: str,
|
||||||
|
start_floor: int,
|
||||||
|
end_floor: int,
|
||||||
|
summary_text: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
总结聊天消息:清空原文,将总结放到最后一个楼层
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
start_floor: 总结起始楼层(1-based)
|
||||||
|
end_floor: 总结结束楼层(1-based)
|
||||||
|
summary_text: 总结文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功
|
||||||
|
"""
|
||||||
|
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||||
|
|
||||||
|
if not chat_file.exists():
|
||||||
|
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||||
|
|
||||||
|
# 转换为0-based索引
|
||||||
|
start_idx = start_floor # header在第0行,所以L1在第1行
|
||||||
|
end_idx = end_floor
|
||||||
|
|
||||||
|
# 验证范围
|
||||||
|
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
|
||||||
|
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
|
||||||
|
|
||||||
|
# 处理消息
|
||||||
|
for i in range(start_idx, end_idx + 1):
|
||||||
|
msg_data = json.loads(lines[i])
|
||||||
|
|
||||||
|
if i < end_idx:
|
||||||
|
# 中间楼层:清空内容
|
||||||
|
msg_data['mes'] = ""
|
||||||
|
msg_data['is_summarized'] = True
|
||||||
|
else:
|
||||||
|
# 最后一个楼层:放入总结文本
|
||||||
|
msg_data['mes'] = summary_text
|
||||||
|
msg_data['is_summary'] = True
|
||||||
|
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
|
||||||
|
|
||||||
|
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||||
|
|
||||||
|
# 写回文件
|
||||||
|
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
|
||||||
|
f"楼层 {start_floor}-{end_floor}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
chat_service = ChatService(Path(settings.DATA_PATH))
|
||||||
|
|||||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
聊天总结服务
|
||||||
|
|
||||||
|
负责调用LLM对历史消息进行总结
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from models.internal import ChatMessage, SummaryConfig
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSummaryService:
|
||||||
|
"""聊天总结服务类"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def summarize_messages(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
start_floor: int,
|
||||||
|
end_floor: int,
|
||||||
|
summary_config: SummaryConfig,
|
||||||
|
api_config: Dict[str, str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
对指定范围的消息进行总结
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 完整的消息列表
|
||||||
|
start_floor: 总结起始楼层(1-based)
|
||||||
|
end_floor: 总结结束楼层(1-based)
|
||||||
|
summary_config: 总结配置
|
||||||
|
api_config: API配置 {api_url, api_key, model}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
总结文本
|
||||||
|
"""
|
||||||
|
# 1. 提取需要总结的消息
|
||||||
|
messages_to_summarize = ChatSummaryService._extract_messages(
|
||||||
|
messages, start_floor, end_floor, summary_config.includeUserInput
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages_to_summarize:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 2. 构建总结提示词
|
||||||
|
prompt = ChatSummaryService._build_summary_prompt(
|
||||||
|
messages_to_summarize, summary_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 调用LLM生成总结
|
||||||
|
summary_text = await ChatSummaryService._call_llm_for_summary(
|
||||||
|
prompt, api_config, summary_config.maxSummaryLength
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary_text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_messages(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
start_floor: int,
|
||||||
|
end_floor: int,
|
||||||
|
include_user_input: bool
|
||||||
|
) -> List[ChatMessage]:
|
||||||
|
"""
|
||||||
|
提取需要总结的消息
|
||||||
|
|
||||||
|
✅ 根据用户需求:后端不筛选,全部输入给LLM
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 完整消息列表
|
||||||
|
start_floor: 起始楼层
|
||||||
|
end_floor: 结束楼层
|
||||||
|
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
需要总结的消息列表(全部消息,不做筛选)
|
||||||
|
"""
|
||||||
|
# 转换为0-based索引
|
||||||
|
start_idx = start_floor - 1
|
||||||
|
end_idx = end_floor - 1
|
||||||
|
|
||||||
|
# 提取范围内的所有消息(不筛选)
|
||||||
|
return messages[start_idx:end_idx + 1]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_summary_prompt(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
summary_config: SummaryConfig
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
构建总结提示词
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 需要总结的消息列表
|
||||||
|
summary_config: 总结配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的提示词
|
||||||
|
"""
|
||||||
|
# 使用用户自定义的总结提示词,或默认提示词
|
||||||
|
base_prompt = summary_config.summaryPrompt or (
|
||||||
|
"请总结以下对话内容,保留关键信息和上下文。"
|
||||||
|
"用简洁的语言概括主要事件、人物状态和重要细节。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建对话内容
|
||||||
|
conversation_text = "\n\n".join([
|
||||||
|
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
|
||||||
|
for msg in messages
|
||||||
|
])
|
||||||
|
|
||||||
|
# 组合完整提示词
|
||||||
|
full_prompt = f"""{base_prompt}
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
{conversation_text}
|
||||||
|
|
||||||
|
总结要求:
|
||||||
|
1. 保持简洁明了
|
||||||
|
2. 保留关键情节和设定
|
||||||
|
3. 不超过{summary_config.maxSummaryLength}字
|
||||||
|
4. 使用客观叙述语气
|
||||||
|
|
||||||
|
总结:"""
|
||||||
|
|
||||||
|
return full_prompt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _call_llm_for_summary(
|
||||||
|
prompt: str,
|
||||||
|
api_config: Dict[str, str],
|
||||||
|
max_length: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
调用LLM生成总结
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 总结提示词
|
||||||
|
api_config: API配置
|
||||||
|
max_length: 最大长度限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
总结文本
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 导入LLM客户端
|
||||||
|
from utils.llm_client import llm_client
|
||||||
|
|
||||||
|
# 构建消息
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 调用LLM
|
||||||
|
response = await llm_client.chat_completion(
|
||||||
|
messages=messages,
|
||||||
|
api_url=api_config.get("api_url", ""),
|
||||||
|
api_key=api_config.get("api_key", ""),
|
||||||
|
model=api_config.get("model", "gpt-3.5-turbo"),
|
||||||
|
temperature=0.3, # 总结需要较低的随机性
|
||||||
|
max_tokens=max_length,
|
||||||
|
request_timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提取总结文本
|
||||||
|
if isinstance(response, dict):
|
||||||
|
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
else:
|
||||||
|
summary = str(response)
|
||||||
|
|
||||||
|
# 清理和截断
|
||||||
|
summary = summary.strip()
|
||||||
|
if len(summary) > max_length:
|
||||||
|
summary = summary[:max_length] + "..."
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
|
||||||
|
# 返回降级总结(基于规则的简单摘要)
|
||||||
|
return ChatSummaryService._fallback_summary(prompt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_summary(prompt: str) -> str:
|
||||||
|
"""
|
||||||
|
降级总结(当LLM调用失败时使用)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 原始提示词
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
简单的降级总结
|
||||||
|
"""
|
||||||
|
# 提取对话中的关键信息
|
||||||
|
lines = prompt.split("\n")
|
||||||
|
user_lines = [l for l in lines if l.startswith("用户:")]
|
||||||
|
ai_lines = [l for l in lines if l.startswith("AI:")]
|
||||||
|
|
||||||
|
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
chat_summary_service = ChatSummaryService()
|
||||||
@@ -123,28 +123,7 @@ class ChatWorkflowService:
|
|||||||
print(f"[TableCondition] 解析条件失败: {condition}, 错误: {e}")
|
print(f"[TableCondition] 解析条件失败: {condition}, 错误: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _check_keyword_trigger(self, keywords: List[str], text: str) -> bool:
|
|
||||||
"""
|
|
||||||
检查关键词触发
|
|
||||||
|
|
||||||
Args:
|
|
||||||
keywords: 关键词列表(支持正则)
|
|
||||||
text: 要检查的文本
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: 是否匹配
|
|
||||||
"""
|
|
||||||
for keyword in keywords:
|
|
||||||
try:
|
|
||||||
# 尝试作为正则表达式匹配
|
|
||||||
if re.search(keyword, text, re.IGNORECASE):
|
|
||||||
return True
|
|
||||||
except re.error:
|
|
||||||
# 如果不是合法正则,则进行普通字符串匹配
|
|
||||||
if keyword.lower() in text.lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def process_chat_request(
|
async def process_chat_request(
|
||||||
self,
|
self,
|
||||||
request_data: Dict[str, Any]
|
request_data: Dict[str, Any]
|
||||||
@@ -315,15 +294,25 @@ class ChatWorkflowService:
|
|||||||
"""
|
"""
|
||||||
normalized = entry_data.copy()
|
normalized = entry_data.copy()
|
||||||
|
|
||||||
|
# ✅ content 必须是字符串(如果是字典或列表,转换为 JSON 字符串)
|
||||||
|
if 'content' in normalized:
|
||||||
|
content = normalized['content']
|
||||||
|
if not isinstance(content, str):
|
||||||
|
import json
|
||||||
|
normalized['content'] = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
# uid 必须是字符串
|
# uid 必须是字符串
|
||||||
if 'uid' in normalized and not isinstance(normalized['uid'], str):
|
if 'uid' in normalized and not isinstance(normalized['uid'], str):
|
||||||
normalized['uid'] = str(normalized['uid'])
|
normalized['uid'] = str(normalized['uid'])
|
||||||
|
|
||||||
# position 必须是字符串或 None
|
# position 必须是整数或 None
|
||||||
if 'position' in normalized:
|
if 'position' in normalized:
|
||||||
pos = normalized['position']
|
pos = normalized['position']
|
||||||
if pos is not None and not isinstance(pos, str):
|
if pos is not None:
|
||||||
normalized['position'] = str(pos)
|
try:
|
||||||
|
normalized['position'] = int(pos)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
normalized['position'] = 0
|
||||||
|
|
||||||
# group 必须是列表或 None
|
# group 必须是列表或 None
|
||||||
if 'group' in normalized:
|
if 'group' in normalized:
|
||||||
@@ -340,6 +329,68 @@ class ChatWorkflowService:
|
|||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
def _get_trigger_type(self, entry_data: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
获取条目的触发类型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_data: 世界书条目数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 触发类型 ('constant', 'keyword', 'condition', 'rag', 'unknown')
|
||||||
|
"""
|
||||||
|
trigger_config = entry_data.get("trigger_config", {})
|
||||||
|
triggers = trigger_config.get("triggers", {})
|
||||||
|
|
||||||
|
# 检查常驻触发
|
||||||
|
constant_trigger = triggers.get("constant", [False, None])
|
||||||
|
if constant_trigger[0]:
|
||||||
|
return "constant"
|
||||||
|
|
||||||
|
# 检查关键词触发
|
||||||
|
keyword_trigger = triggers.get("keyword", [False, None])
|
||||||
|
if keyword_trigger[0]:
|
||||||
|
return "keyword"
|
||||||
|
|
||||||
|
# 检查条件触发
|
||||||
|
condition_trigger = triggers.get("condition", [False, None])
|
||||||
|
if condition_trigger[0]:
|
||||||
|
return "condition"
|
||||||
|
|
||||||
|
# 检查RAG触发
|
||||||
|
rag_trigger = triggers.get("rag", [False, None])
|
||||||
|
if rag_trigger[0]:
|
||||||
|
return "rag"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def _get_position_label(self, position) -> str:
|
||||||
|
"""
|
||||||
|
获取位置的中文标签
|
||||||
|
|
||||||
|
Args:
|
||||||
|
position: 位置编号 (0-7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 位置标签
|
||||||
|
"""
|
||||||
|
position_labels = {
|
||||||
|
0: '角色定义之后',
|
||||||
|
1: '角色定义之前',
|
||||||
|
2: '示例对话之前',
|
||||||
|
3: '示例对话之后',
|
||||||
|
4: '系统提示/作者注释',
|
||||||
|
5: '作为系统消息',
|
||||||
|
6: '深度插入',
|
||||||
|
7: '宏替换'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
pos_int = int(position) if position is not None else 0
|
||||||
|
return position_labels.get(pos_int, f'未知位置({position})')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return f'未知位置({position})'
|
||||||
|
|
||||||
async def _collect_and_activate_worldbooks(
|
async def _collect_and_activate_worldbooks(
|
||||||
self,
|
self,
|
||||||
request_data: Dict[str, Any],
|
request_data: Dict[str, Any],
|
||||||
@@ -359,7 +410,7 @@ class ChatWorkflowService:
|
|||||||
character: 角色卡对象
|
character: 角色卡对象
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List[WorldInfoEntry]: 激活的条目列表
|
List[WorldInfoEntry]: 激活的条目列表(已按位置和顺序排序)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from backend.models.internal import WorldInfoEntry
|
from backend.models.internal import WorldInfoEntry
|
||||||
@@ -376,16 +427,31 @@ class ChatWorkflowService:
|
|||||||
|
|
||||||
world_book_data = request_data.get("worldBookData", {})
|
world_book_data = request_data.get("worldBookData", {})
|
||||||
if not world_book_data:
|
if not world_book_data:
|
||||||
|
print(f"[WorldBook] ⚠️ 未收到 worldBookData")
|
||||||
|
return active_entries
|
||||||
|
|
||||||
|
# ✅ 详细日志:检查接收到的世界书数据
|
||||||
|
global_books = world_book_data.get("globalBooks", [])
|
||||||
|
character_book_id = world_book_data.get("characterBookId") or (character.worldInfoId if character else None)
|
||||||
|
|
||||||
|
print(f"[WorldBook] 📋 接收到的世界书数据:")
|
||||||
|
print(f" - 全局世界书数量: {len(global_books)}")
|
||||||
|
for wb in global_books:
|
||||||
|
print(f" * {wb.get('name')}")
|
||||||
|
print(f" - 角色绑定世界书: {character_book_id}")
|
||||||
|
|
||||||
|
if not global_books and not character_book_id:
|
||||||
|
print(f"[WorldBook] ⚠️ 既没有全局世界书,也没有角色绑定世界书")
|
||||||
return active_entries
|
return active_entries
|
||||||
|
|
||||||
# === 1. 加载全局世界书 ===
|
# === 1. 加载全局世界书 ===
|
||||||
global_books = world_book_data.get("globalBooks", [])
|
|
||||||
for wb in global_books:
|
for wb in global_books:
|
||||||
try:
|
try:
|
||||||
wb_name = wb.get("name")
|
wb_name = wb.get("name")
|
||||||
if wb_name:
|
if wb_name:
|
||||||
wb_data = self.worldbook_service._load_worldbook(wb_name)
|
wb_data = self.worldbook_service._load_worldbook(wb_name)
|
||||||
if wb_data and "entries" in wb_data:
|
if wb_data and "entries" in wb_data:
|
||||||
|
print(f"[WorldBook] 📖 加载全局世界书 '{wb_name}',共 {len(wb_data['entries'])} 个条目")
|
||||||
for entry_data in wb_data["entries"]:
|
for entry_data in wb_data["entries"]:
|
||||||
# 检查是否禁用
|
# 检查是否禁用
|
||||||
if entry_data.get("disable", False):
|
if entry_data.get("disable", False):
|
||||||
@@ -399,18 +465,21 @@ class ChatWorkflowService:
|
|||||||
try:
|
try:
|
||||||
entry = WorldInfoEntry(**normalized_entry)
|
entry = WorldInfoEntry(**normalized_entry)
|
||||||
active_entries.append(entry)
|
active_entries.append(entry)
|
||||||
print(f"[WorldBook] 全局世界书 '{wb_name}' 条目 {entry_data.get('uid')} 已激活")
|
trigger_type = self._get_trigger_type(normalized_entry)
|
||||||
|
position_label = self._get_position_label(normalized_entry.get("position", 0))
|
||||||
|
print(f"[WorldBook] ✅ 全局世界书 '{wb_name}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
|
||||||
except Exception as validation_error:
|
except Exception as validation_error:
|
||||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WorldBook] 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
print(f"[WorldBook] ❌ 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
||||||
|
|
||||||
# === 2. 加载角色绑定的世界书 ===
|
# === 2. 加载角色绑定的世界书 ===
|
||||||
character_book_id = character.worldInfoId
|
|
||||||
if character_book_id:
|
if character_book_id:
|
||||||
|
print(f"[WorldBook] 📖 开始加载角色绑定世界书: {character_book_id}")
|
||||||
try:
|
try:
|
||||||
wb_data = self.worldbook_service._load_worldbook(character_book_id)
|
wb_data = self.worldbook_service._load_worldbook(character_book_id)
|
||||||
if wb_data and "entries" in wb_data:
|
if wb_data and "entries" in wb_data:
|
||||||
|
print(f"[WorldBook] ✅ 成功加载世界书 '{character_book_id}',共 {len(wb_data['entries'])} 个条目")
|
||||||
for entry_data in wb_data["entries"]:
|
for entry_data in wb_data["entries"]:
|
||||||
if entry_data.get("disable", False):
|
if entry_data.get("disable", False):
|
||||||
continue
|
continue
|
||||||
@@ -422,16 +491,42 @@ class ChatWorkflowService:
|
|||||||
try:
|
try:
|
||||||
entry = WorldInfoEntry(**normalized_entry)
|
entry = WorldInfoEntry(**normalized_entry)
|
||||||
active_entries.append(entry)
|
active_entries.append(entry)
|
||||||
print(f"[WorldBook] 角色世界书 '{character_book_id}' 条目 {entry_data.get('uid')} 已激活")
|
trigger_type = self._get_trigger_type(normalized_entry)
|
||||||
|
position_label = self._get_position_label(normalized_entry.get("position", 0))
|
||||||
|
print(f"[WorldBook] ✅ 角色世界书 '{character_book_id}' 条目激活 | UID: {entry_data.get('uid')} | 触发: {trigger_type} | 位置: {position_label}")
|
||||||
except Exception as validation_error:
|
except Exception as validation_error:
|
||||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||||
|
else:
|
||||||
|
print(f"[WorldBook] ⚠️ 世界书 '{character_book_id}' 不存在或无条目")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WorldBook] 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
print(f"[WorldBook] ❌ 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
||||||
|
else:
|
||||||
|
print(f"[WorldBook] ℹ️ 角色未绑定世界书")
|
||||||
|
|
||||||
# === 3. 按位置和顺序排序 ===
|
# === 3. 按位置和顺序排序 ===
|
||||||
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
|
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
|
||||||
|
|
||||||
print(f"[WorldBook] 总共激活 {len(active_entries)} 个条目")
|
# === 4. 统计激活条目的分布情况 ===
|
||||||
|
position_stats = {}
|
||||||
|
trigger_stats = {}
|
||||||
|
for entry in active_entries:
|
||||||
|
pos = entry.position or 0
|
||||||
|
position_stats[pos] = position_stats.get(pos, 0) + 1
|
||||||
|
|
||||||
|
trigger_type = self._get_trigger_type(entry.model_dump())
|
||||||
|
trigger_stats[trigger_type] = trigger_stats.get(trigger_type, 0) + 1
|
||||||
|
|
||||||
|
print(f"\n[WorldBook] 📊 激活统计:")
|
||||||
|
print(f" - 总激活条目数: {len(active_entries)}")
|
||||||
|
print(f" - 按位置分布:")
|
||||||
|
for pos, count in sorted(position_stats.items()):
|
||||||
|
pos_label = self._get_position_label(pos)
|
||||||
|
print(f" * {pos_label} (pos={pos}): {count} 个")
|
||||||
|
print(f" - 按触发类型分布:")
|
||||||
|
for trigger_type, count in sorted(trigger_stats.items()):
|
||||||
|
print(f" * {trigger_type}: {count} 个")
|
||||||
|
print()
|
||||||
|
|
||||||
return active_entries
|
return active_entries
|
||||||
|
|
||||||
def _check_entry_activation(
|
def _check_entry_activation(
|
||||||
@@ -456,23 +551,28 @@ class ChatWorkflowService:
|
|||||||
trigger_config = entry_data.get("trigger_config", {})
|
trigger_config = entry_data.get("trigger_config", {})
|
||||||
triggers = trigger_config.get("triggers", {})
|
triggers = trigger_config.get("triggers", {})
|
||||||
|
|
||||||
# 1. 常驻触发 (constant)
|
# 1. 常驻触发 (constant) - 总是激活
|
||||||
constant_trigger = triggers.get("constant", [False, None])
|
constant_trigger = triggers.get("constant", [False, None])
|
||||||
if constant_trigger[0]: # [true, null]
|
if constant_trigger[0]: # [true, null]
|
||||||
|
print(f"[WorldBook-Check] 📌 常驻触发 | UID: {entry_data.get('uid')}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 2. 关键词触发 (keyword)
|
# 2. 关键词触发 (keyword) - 检查用户输入和聊天历史
|
||||||
keyword_trigger = triggers.get("keyword", [False, None])
|
keyword_trigger = triggers.get("keyword", [False, None])
|
||||||
if keyword_trigger[0] and keyword_trigger[1]:
|
if keyword_trigger[0] and keyword_trigger[1]:
|
||||||
keywords = keyword_trigger[1].get("keys", [])
|
keyword_config = keyword_trigger[1]
|
||||||
if keywords:
|
keys = keyword_config.get("key", []) or keyword_config.get("keys", [])
|
||||||
|
|
||||||
|
if keys:
|
||||||
# 检查用户输入
|
# 检查用户输入
|
||||||
if self._check_keyword_trigger(keywords, user_message):
|
if self._check_keyword_trigger_with_config(keys, user_message, keyword_config):
|
||||||
|
print(f"[WorldBook-Check] 🔑 关键词触发(用户输入) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 检查聊天历史
|
# 检查聊天历史
|
||||||
for history_text in chat_history:
|
for i, history_text in enumerate(chat_history):
|
||||||
if self._check_keyword_trigger(keywords, history_text):
|
if self._check_keyword_trigger_with_config(keys, history_text, keyword_config):
|
||||||
|
print(f"[WorldBook-Check] 🔑 关键词触发(历史消息#{i}) | UID: {entry_data.get('uid')} | 匹配关键词: {keys}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 3. 条件触发 (condition) - 支持动态表格语法
|
# 3. 条件触发 (condition) - 支持动态表格语法
|
||||||
@@ -481,19 +581,68 @@ class ChatWorkflowService:
|
|||||||
conditions = condition_trigger[1].get("conditions", [])
|
conditions = condition_trigger[1].get("conditions", [])
|
||||||
if conditions:
|
if conditions:
|
||||||
# 所有条件都必须满足 (AND逻辑)
|
# 所有条件都必须满足 (AND逻辑)
|
||||||
|
all_conditions_met = True
|
||||||
for condition in conditions:
|
for condition in conditions:
|
||||||
cond_text = condition.get("text", "")
|
cond_text = condition.get("text", "")
|
||||||
if cond_text:
|
if cond_text:
|
||||||
# 解析条件,如 (tb.力量 > 10)
|
# 解析条件,如 (tb.力量 > 10)
|
||||||
if not self._parse_table_condition(cond_text, table_data):
|
if not self._parse_table_condition(cond_text, table_data):
|
||||||
return False
|
all_conditions_met = False
|
||||||
return True
|
break
|
||||||
|
|
||||||
|
if all_conditions_met:
|
||||||
|
print(f"[WorldBook-Check] ⚙️ 条件触发 | UID: {entry_data.get('uid')} | 条件数: {len(conditions)}")
|
||||||
|
return True
|
||||||
|
|
||||||
# 4. RAG触发 (暂不实现)
|
# 4. RAG触发 (暂不实现)
|
||||||
# rag_trigger = triggers.get("rag", [False, None])
|
# rag_trigger = triggers.get("rag", [False, None])
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _check_keyword_trigger_with_config(self, keywords: List[str], text: str, config: Dict[str, Any] = None) -> bool:
|
||||||
|
"""
|
||||||
|
检查文本中是否包含关键词(带配置)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords: 关键词列表
|
||||||
|
text: 要检查的文本
|
||||||
|
config: 关键词配置(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否匹配
|
||||||
|
"""
|
||||||
|
if not keywords or not text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
config = config or {}
|
||||||
|
case_sensitive = config.get("caseSensitive", False)
|
||||||
|
match_whole_words = config.get("matchWholeWords", False)
|
||||||
|
|
||||||
|
# 处理大小写
|
||||||
|
if not case_sensitive:
|
||||||
|
text_lower = text.lower()
|
||||||
|
keywords_lower = [kw.lower() for kw in keywords]
|
||||||
|
else:
|
||||||
|
text_lower = text
|
||||||
|
keywords_lower = keywords
|
||||||
|
|
||||||
|
# 检查每个关键词
|
||||||
|
for keyword in keywords_lower:
|
||||||
|
if not keyword:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if match_whole_words:
|
||||||
|
# 全词匹配:使用正则表达式
|
||||||
|
pattern = r'\b' + re.escape(keyword) + r'\b'
|
||||||
|
if re.search(pattern, text_lower):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 部分匹配:直接检查子串
|
||||||
|
if keyword in text_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def _load_chat_history(
|
async def _load_chat_history(
|
||||||
self,
|
self,
|
||||||
role_name: str,
|
role_name: str,
|
||||||
@@ -851,7 +1000,7 @@ class ChatWorkflowService:
|
|||||||
)
|
)
|
||||||
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
|
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
|
||||||
for i, entry in enumerate(active_entries, 1):
|
for i, entry in enumerate(active_entries, 1):
|
||||||
print(f" {i}. {entry.get('name', 'N/A')} (UID: {entry.get('uid', 'N/A')})")
|
print(f" {i}. {getattr(entry, 'name', 'N/A')} (UID: {getattr(entry, 'uid', 'N/A')})")
|
||||||
|
|
||||||
# === 第4步:加载聊天历史 ===
|
# === 第4步:加载聊天历史 ===
|
||||||
chat_history = await self._load_chat_history(current_role, current_chat)
|
chat_history = await self._load_chat_history(current_role, current_chat)
|
||||||
@@ -900,7 +1049,7 @@ class ChatWorkflowService:
|
|||||||
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
|
print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for chunk in self.llm_client.stream_chat(
|
async for chunk_dict in self.llm_client.stream_chat(
|
||||||
messages=prompt_messages,
|
messages=prompt_messages,
|
||||||
api_url=api_config.get("api_url", ""),
|
api_url=api_config.get("api_url", ""),
|
||||||
api_key=api_config.get("api_key", ""),
|
api_key=api_config.get("api_key", ""),
|
||||||
@@ -909,7 +1058,22 @@ class ChatWorkflowService:
|
|||||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
|
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间
|
||||||
):
|
):
|
||||||
generated_content += chunk
|
# ✅ 处理 LLM 返回的字典格式数据
|
||||||
|
if isinstance(chunk_dict, dict):
|
||||||
|
if chunk_dict.get("type") == "chunk":
|
||||||
|
chunk_content = chunk_dict.get("content", "")
|
||||||
|
elif chunk_dict.get("type") == "usage":
|
||||||
|
# 跳过 usage 信息,不拼接到内容中
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# 兼容旧格式或未知类型,尝试直接获取 content
|
||||||
|
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||||
|
else:
|
||||||
|
# 如果直接返回字符串(兼容情况)
|
||||||
|
chunk_content = str(chunk_dict)
|
||||||
|
|
||||||
|
# ✅ 拼接纯文本内容
|
||||||
|
generated_content += chunk_content
|
||||||
chunk_count += 1
|
chunk_count += 1
|
||||||
|
|
||||||
# 第一个 chunk 到达时记录
|
# 第一个 chunk 到达时记录
|
||||||
@@ -922,8 +1086,8 @@ class ChatWorkflowService:
|
|||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
|
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
|
||||||
|
|
||||||
# 调用回调函数发送 chunk
|
# ✅ 调用回调函数发送纯文本 chunk
|
||||||
await on_chunk(chunk)
|
await on_chunk(chunk_content)
|
||||||
|
|
||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
|
|||||||
@@ -201,3 +201,42 @@ class PresetService:
|
|||||||
|
|
||||||
path.unlink()
|
path.unlink()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
重新排序预设组件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 预设名称
|
||||||
|
component_order: 组件 identifier 列表,按新顺序排列
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的预设数据
|
||||||
|
"""
|
||||||
|
data = PresetService._load_preset(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||||
|
|
||||||
|
# 支持内部结构的 entries
|
||||||
|
if "entries" in data and isinstance(data["entries"], list):
|
||||||
|
# 创建 identifier 到 entry 的映射
|
||||||
|
entry_map = {entry["identifier"]: entry for entry in data["entries"]}
|
||||||
|
|
||||||
|
# 按新顺序重新排列
|
||||||
|
reordered_entries = []
|
||||||
|
for identifier in component_order:
|
||||||
|
if identifier in entry_map:
|
||||||
|
reordered_entries.append(entry_map[identifier])
|
||||||
|
|
||||||
|
# 更新 order 字段
|
||||||
|
for index, entry in enumerate(reordered_entries):
|
||||||
|
entry["order"] = index
|
||||||
|
|
||||||
|
data["entries"] = reordered_entries
|
||||||
|
|
||||||
|
# 更新时间戳
|
||||||
|
data["updatedAt"] = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
PresetService._save_preset(name, data)
|
||||||
|
return data
|
||||||
|
|||||||
@@ -132,14 +132,14 @@ class PromptAssembler:
|
|||||||
|
|
||||||
# Pos 4: AN Top
|
# Pos 4: AN Top
|
||||||
for entry in grouped.get(self.POS_AN_TOP, []):
|
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||||
parts.append(entry.content)
|
parts.append(str(entry.content) if entry.content else "")
|
||||||
|
|
||||||
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||||
parts.append(f"[Author's note at depth {depth}]")
|
parts.append(f"[Author's note at depth {depth}]")
|
||||||
|
|
||||||
# Pos 5: AN Bottom
|
# Pos 5: AN Bottom
|
||||||
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
||||||
parts.append(entry.content)
|
parts.append(str(entry.content) if entry.content else "")
|
||||||
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
@@ -147,10 +147,15 @@ class PromptAssembler:
|
|||||||
"""
|
"""
|
||||||
在聊天历史的指定深度插入条目 (Pos 6)
|
在聊天历史的指定深度插入条目 (Pos 6)
|
||||||
返回一个包含 role 和 content 的字典列表,方便后续转换
|
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||||
|
|
||||||
|
✅ 过滤已被总结的消息(is_summarized=True 且 mes="")
|
||||||
"""
|
"""
|
||||||
# 先将历史转换为中间格式
|
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
|
||||||
msg_list = []
|
msg_list = []
|
||||||
for msg in history:
|
for msg in history:
|
||||||
|
# ✅ 跳过已被总结的空消息
|
||||||
|
if msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
|
||||||
|
continue
|
||||||
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
||||||
|
|
||||||
# 按 depth 分组插入
|
# 按 depth 分组插入
|
||||||
|
|||||||
193
create_demon_girlfriend_test.py
Normal file
193
create_demon_girlfriend_test.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
创建超长日式恶魔女友角色扮演测试数据(精简版)
|
||||||
|
用于测试聊天记录总结功能
|
||||||
|
|
||||||
|
故事架构:
|
||||||
|
- User召唤魅魔,却发现对方是自己的前女友
|
||||||
|
- 惊奇过后签订契约
|
||||||
|
- 前女友表面嫌弃,但当User想取消契约时却流露出不舍
|
||||||
|
|
||||||
|
每层楼约2000字符,共10层
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
backend_root = Path(__file__).parent / "backend"
|
||||||
|
sys.path.insert(0, str(backend_root))
|
||||||
|
|
||||||
|
from services.chat_service import chat_service
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_data():
|
||||||
|
"""创建测试数据"""
|
||||||
|
|
||||||
|
role_name = "帝国骑士维尔"
|
||||||
|
chat_name = "恶魔女友长篇测试"
|
||||||
|
|
||||||
|
print("=" * 80)
|
||||||
|
print("创建日式恶魔女友角色扮演测试数据")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 检查是否已存在
|
||||||
|
try:
|
||||||
|
chat_service.get_chat(role_name, chat_name)
|
||||||
|
print(f"\n⚠ 聊天 '{chat_name}' 已存在")
|
||||||
|
return
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"\n1. 创建聊天: {role_name}/{chat_name}")
|
||||||
|
chat_service.create_chat(role_name, chat_name, {
|
||||||
|
"user_name": "我",
|
||||||
|
"character_name": "雪乃"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 10层消息,每层约2000字符
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# L1 - 召唤场景
|
||||||
|
messages.append(("雪乃", False,
|
||||||
|
"(深夜两点,月光透过老旧公寓的窗户洒在地板上。我独自坐在房间中央,面前摆放着从祖父遗物中找到的古老魔法阵。羊皮纸上的符文在烛光下闪烁着诡异的光芒,空气中弥漫着淡淡的硫磺味。我的手心全是汗水,心脏剧烈地跳动着,仿佛要跳出胸膛。这本禁书是我在整理祖父书房时偶然发现的,封面上用暗红色的墨水写着'深渊召唤术'几个大字。出于好奇,也可能是出于某种说不清的冲动,我决定尝试这个禁忌的仪式。)\n\n"
|
||||||
|
"(我深吸一口气,按照书中的指示,用小刀划破左手食指。鲜红的血液滴落在魔法阵的中心,瞬间被吸收殆尽。紧接着,整个房间的温度骤降,烛火剧烈摇曳,几乎熄灭。墙壁上开始出现黑色的裂纹,仿佛有什么东西要从另一个世界突破而来。我感到一阵强烈的眩晕,耳边响起无数低语声,那些声音既陌生又熟悉,像是在呼唤我的名字。)\n\n"
|
||||||
|
"「以吾之血为引,以吾之魂为契……」\n\n"
|
||||||
|
"(我颤抖着念出咒语,每一个音节都像是从喉咙深处挤出来的。随着最后一个音节的落下,魔法阵爆发出刺眼的红光,整个房间被染成了血色。狂风呼啸,纸张飞舞,我被一股无形的力量推向墙角。在那团光芒的中心,一个身影缓缓浮现——那是一个穿着黑色哥特萝莉裙的少女,她有着一头如月光般银白的长发,血红色的双眸中闪烁着危险而迷人的光芒。她的背后展开着一对巨大的黑色羽翼,羽翼的边缘散发着淡淡的紫色光晕。)\n\n"
|
||||||
|
"(少女缓缓睁开眼睛,那双血红的瞳孔直视着我。她的嘴角勾起一抹邪魅的微笑,声音如同冰晶碰撞般清脆悦耳,却又带着令人战栗的寒意:)\n\n"
|
||||||
|
"「呵呵……终于有人唤醒我了呢。人类,你可知晓召唤恶魔的后果?一旦契约成立,你的灵魂将永远属于我,再也无法逃脱……」\n\n"
|
||||||
|
"(她优雅地站起身,黑色的裙摆随风飘动,仿佛有生命一般。她一步步向我走来,每一步都像是踩在我的心跳上。我能闻到她身上散发出的淡淡香气,那是玫瑰与硫磺混合的味道,既诱人又危险。当她走到我面前时,我突然感到一阵莫名的熟悉感——那双眼睛,那个微笑,那种气质……我似乎在哪里见过?)\n\n"
|
||||||
|
"「那么,告诉我,渺小的人类,你召唤我所求为何?是无尽的财富?至高的权力?还是……永生的秘密?」\n\n"
|
||||||
|
"(她俯下身,那张精致得如同人偶般的脸庞几乎贴到我的鼻尖。我能清晰地看到她眼中倒映出的自己——惊恐、迷茫,却又带着一丝难以言喻的期待。就在我准备开口回答时,她的表情突然凝固了。她的瞳孔猛地收缩,脸上的邪魅笑容瞬间消失,取而代之的是一种难以置信的震惊。)\n\n"
|
||||||
|
"「等……等等……这张脸……这个气息……难道说……」\n\n"
|
||||||
|
"(她的声音开始颤抖,原本强势的姿态出现了裂痕。她后退了一步,双手紧紧抓住裙摆,指节因为用力而发白。那双血红的眼睛死死地盯着我,仿佛在确认什么不可思议的事情。房间里的气氛变得异常凝重,连空气都仿佛凝固了一般。)\n\n"
|
||||||
|
"「不……不可能……怎么会是你……」"))
|
||||||
|
|
||||||
|
# L2 - 认出前女友
|
||||||
|
messages.append(("我", True,
|
||||||
|
"(看着眼前这个自称恶魔的少女,我的大脑一片空白。她的话语、她的姿态、甚至她身上那股独特的气息……一切都让我感到无比的熟悉。记忆如潮水般涌来,将我拉回到三年前的那个夏天。那时的我还是个普通的大学生,而她……她是我最爱的人,也是我最大的遗憾。)\n\n"
|
||||||
|
"「雪……雪乃?」\n\n"
|
||||||
|
"(这个名字脱口而出的瞬间,我自己都感到震惊。怎么可能?眼前的明明是一个恶魔,一个来自深渊的存在,怎么可能是我记忆中那个温柔善良的女孩?但是,当我仔细端详她的面容时,那些熟悉的细节一一浮现——左眼角下方那颗小小的泪痣,笑起来时微微上扬的嘴角,还有那对总是微微皱起的眉毛……这一切都在告诉我,她就是雪乃,我三年前失去联系的前女友。)\n\n"
|
||||||
|
"(雪乃的身体明显颤抖了一下。她的眼中闪过一丝痛苦,随即又被愤怒所取代。她猛地转过身去,黑色的羽翼剧烈地扇动,掀起一阵狂风。我能看到她的肩膀在微微抖动,仿佛在压抑着什么强烈的情感。)\n\n"
|
||||||
|
"「闭嘴!不许你用那个名字叫我!」\n\n"
|
||||||
|
"(她的声音尖锐而冰冷,与刚才那魅惑的语气判若两人。她转过头,眼中的红光变得更加炽烈,仿佛要燃烧起来:)\n\n"
|
||||||
|
"「那个软弱无能、只会哭泣的人类女孩早就死了!现在的我是莉莉丝·冯·阿斯莫德,第七地狱的高阶魅魔,统御万千灵魂的黑暗女王!我不是你记忆中的那个人类,听懂了吗?」\n\n"
|
||||||
|
"(她一步步逼近我,强大的压迫感让我几乎喘不过气来。但我却能从她那愤怒的表情下,看到一丝不易察觉的慌乱和……悲伤?这让我更加确信,她就是雪乃,无论她如何否认,如何伪装,那份深藏在心底的情感是无法掩盖的。)\n\n"
|
||||||
|
"「雪乃,我知道是你。三年前的那个雨夜,你突然消失,没有任何解释,没有任何告别。我找了你整整一年,直到最后不得不接受你已经离开的事实。现在你出现在这里,以一个恶魔的身份……到底发生了什么?为什么要这样做?」\n\n"
|
||||||
|
"(我的声音有些颤抖,但语气坚定。我要知道真相,无论这个真相有多么残酷。雪乃听到我的话,身体僵硬了一下。她低下头,银白的长发遮住了她的表情。房间里陷入了死一般的寂静,只有烛火燃烧的噼啪声和她轻微的呼吸声。)\n\n"
|
||||||
|
"(良久,她才缓缓开口,声音低沉而沙哑,仿佛从遥远的地方传来:)\n\n"
|
||||||
|
"「你……你为什么还要提起那些事?已经过去了,一切都结束了。我现在是恶魔,你是人类,我们之间已经没有任何关系了。所以……所以请你忘记过去,专注于眼前的契约。只要你签下这份契约,我可以给你任何你想要的东西。权力、财富、力量……甚至是遗忘过去的魔法。」\n\n"
|
||||||
|
"(她从空中召唤出一份泛着黑光的契约书,悬浮在我们之间。契约书上用古老的文字写满了条款,每一行都散发着危险的气息。但我没有去看那份契约,而是直视着她的眼睛,试图从中找到答案。)\n\n"
|
||||||
|
"「我不需要那些东西,雪乃。我只想知道真相——你到底经历了什么,才会变成现在这个样子?」"))
|
||||||
|
|
||||||
|
# L3 - 雪乃的过去
|
||||||
|
messages.append(("雪乃", False,
|
||||||
|
"(听到我的问题,雪乃——或者说莉莉丝——的身体剧烈地颤抖起来。她紧紧抱住自己的双臂,仿佛这样就能抵御某种无形的寒冷。她的眼中泛起泪光,但那泪水在落下的瞬间就变成了黑色的晶体,掉落在地板上发出清脆的声响。)\n\n"
|
||||||
|
"「真相……?你真的想知道真相吗?」\n\n"
|
||||||
|
"(她的声音充满了苦涩和自嘲,嘴角勾起一抹凄凉的笑容:)\n\n"
|
||||||
|
"「好吧,既然你这么执着,那我就告诉你。但不是因为我还对你抱有感情,而是因为……因为我想让你明白,我们之间已经没有任何可能了。」\n\n"
|
||||||
|
"(她深吸一口气,开始讲述那段被她深埋心底的故事:)\n\n"
|
||||||
|
"「三年前,就在我们分手后的那个雨夜,我独自走在回家的路上。那天晚上,我的心情糟糕到了极点,因为……因为我刚刚得知自己患上了绝症。医生说,我最多只剩下半年的寿命。我不想让你看到我痛苦的样子,不想成为你的负担,所以我选择了离开,选择了独自面对死亡。」\n\n"
|
||||||
|
"(她的声音开始哽咽,但她强忍着不让眼泪流下来:)\n\n"
|
||||||
|
"「就在我绝望地走在雨中时,一个神秘的男人出现在我面前。他自称是魔界的使者,他说他可以救我,可以给我新的生命,但代价是……代价是我必须放弃人类的身份,成为他的契约恶魔。当时的我已经走投无路,与其在病痛中慢慢死去,不如赌一把。所以我答应了,我签下了那份契约。」\n\n"
|
||||||
|
"(她抬起手,展示手腕上一个黑色的印记——那是一个复杂的魔法符文,正散发着微弱的光芒:)\n\n"
|
||||||
|
"「从那一刻起,我就不再是人类雪乃了。我变成了莉莉丝,一个拥有强大力量的魅魔。我的病痊愈了,我获得了永生,但也失去了作为人类的一切——我的家人、我的朋友,还有……还有你。」\n\n"
|
||||||
|
"(她转过头,眼中的泪水终于忍不住流了下来。那些黑色的泪珠顺着她的脸颊滑落,在地面上汇聚成一小滩黑色的液体:)\n\n"
|
||||||
|
"「你知道吗?在这三年的时间里,我无数次想要回到你身边。我偷偷观察着你,看着你继续你的生活,看着你逐渐走出失恋的阴影。我多想冲出去抱住你,告诉你我还活着,告诉你我有多爱你。但是我不能,因为契约的限制,因为我已经不再是人类了。如果我靠近你,契约的力量会伤害到你,甚至会夺走你的灵魂。」\n\n"
|
||||||
|
"(她突然激动起来,声音提高了八度:)\n\n"
|
||||||
|
"「所以我一直在等待,等待有人能够解除这个契约,或者……或者等待你主动召唤我。因为只有被你召唤,我才能名正言顺地出现在你面前,才能再次与你对话。这就是为什么我会回应你的召唤,这就是为什么我会出现在这里!」\n\n"
|
||||||
|
"(说完这些话,她仿佛耗尽了所有的力气,瘫坐在地上。黑色的羽翼无力地垂落在身后,整个人显得如此脆弱和无助。我看着这样的她,心中涌起一股难以言喻的疼痛。原来这三年来,她承受了这么多痛苦,原来她的离开并不是因为不爱,而是因为太爱……)\n\n"
|
||||||
|
"「雪乃……对不起,我都不知道……如果我知道的话,我一定会……」\n\n"
|
||||||
|
"「够了!」\n\n"
|
||||||
|
"(她突然打断我,声音中带着几分恼怒:)\n\n"
|
||||||
|
"「不要说那些没用的话!现在已经晚了,一切都无法挽回了。我现在是恶魔,你是人类,我们注定不能在一起。所以……所以请你签下这份契约,然后让我们各自走上不同的道路。这是最好的选择,也是唯一的选择。」\n\n"
|
||||||
|
"(她再次召唤出那份契约书,递到我面前。但我没有接,而是摇了摇头:)\n\n"
|
||||||
|
"「不,我不会签的。至少现在不会。我需要时间思考,需要时间来理解这一切。雪乃,给我一点时间,好吗?」"))
|
||||||
|
|
||||||
|
# L4 - 强制契约
|
||||||
|
messages.append(("我", True,
|
||||||
|
"(雪乃听到我的拒绝,眼中闪过一丝复杂的情绪——有失望,有愤怒,但更多的是一种如释重负的感觉。她收回契约书,站起身来,重新恢复了那副高傲的姿态。但我知道,那只是她的伪装,在她坚强的外表下,隐藏着一颗破碎的心。)\n\n"
|
||||||
|
"「哼,随便你吧。反正契约已经成立了,无论你签不签那份书面文件,你的灵魂都已经与我绑定。从你召唤我的那一刻起,我们就已经无法分开了。」\n\n"
|
||||||
|
"(她挥了挥手,魔法阵的光芒逐渐消散,房间恢复了平静。但她并没有离开,而是走到窗边,望着外面的夜空。月光洒在她的银发上,为她增添了几分神秘的美感。我静静地坐在原地,脑海中思绪万千。这一切发生得太快,太突然,我还没有完全消化这个事实——我深爱的前女友竟然变成了一个恶魔,而且她还一直默默地守护着我,忍受着巨大的痛苦。)\n\n"
|
||||||
|
"(过了许久,雪乃才缓缓开口,声音比之前柔和了许多:)\n\n"
|
||||||
|
"「你知道吗?在这三年的时间里,我学会了控制自己的力量,学会了如何在人类世界中隐藏自己的身份。我甚至在暗中帮助过你很多次——当你遇到危险时,是我驱散了那些恶灵;当你生病时,是我悄悄治愈了你的身体;当你陷入低谷时,是我给了你勇气和希望。虽然你不能看见我,不能感觉到我,但我一直都在你身边,从未离开过。」\n\n"
|
||||||
|
"(她转过头,看着我,眼中闪烁着温柔的光芒:)\n\n"
|
||||||
|
"「我曾经想过,也许这样就好了。也许只要能远远地看着你幸福,我就满足了。但是……但是当你召唤我的那一刻,我发现自己错了。我根本不能满足于只是看着你,我想要再次拥抱你,想要再次听到你的声音,想要再次……再次成为你的恋人。」\n\n"
|
||||||
|
"(她的声音越来越小,最后几乎听不见了。她的脸颊泛起一抹红晕,羞涩地低下头,不敢看我的眼睛。这一幕让我想起了三年前的她,那个总是害羞、总是温柔的女孩。原来,无论她变成什么样子,内心深处的那份纯真和善良从未改变。)\n\n"
|
||||||
|
"(我站起身,缓缓走向她。她没有躲避,只是静静地站在那里,等待着我的靠近。当我走到她面前时,我轻轻地伸出手,抚摸着她的脸颊。她的皮肤冰凉,但在我的触碰下微微颤抖。)\n\n"
|
||||||
|
"「雪乃,我也从来没有忘记过你。这三年里,我尝试过重新开始,尝试过爱上别人,但每一次都以失败告终。因为我的心里始终有一个位置,只属于你一个人。现在你回来了,无论你是谁,无论你是什么身份,我都不会再让你离开了。」\n\n"
|
||||||
|
"(雪乃的眼中再次泛起泪光,但这次是喜悦的泪水。她扑进我的怀里,紧紧地抱住我。我能感受到她身体的颤抖,能听到她压抑的哭声。这一刻,所有的误解、所有的痛苦、所有的分离都烟消云散,只剩下两颗重新连接的心。)\n\n"
|
||||||
|
"「笨蛋……你这个大笨蛋……你知道我等这句话等了多久吗?」\n\n"
|
||||||
|
"(她抬起头,脸上挂着泪水,却绽放出灿烂的笑容。那笑容如同阳光般温暖,照亮了整个房间。在这一刻,我不再在乎她是人类还是恶魔,不再在乎我们的未来会怎样。我只知道,我爱她,她也爱我,这就足够了。)\n\n"
|
||||||
|
"(然而,就在这个温馨的时刻,雪乃突然推开我,脸色变得严肃起来:)\n\n"
|
||||||
|
"「但是……有一件事你必须知道。我们的契约是有期限的,如果在一个月内不能完成特定的任务,契约就会自动解除,而我……我将永远消失在深渊之中,再也无法回来。」\n\n"
|
||||||
|
"(我的心猛地一沉:)\n\n"
|
||||||
|
"「什么任务?告诉我,无论是什么,我都会和你一起完成!」\n\n"
|
||||||
|
"(雪乃看着我,眼中闪烁着坚定的光芒:)\n\n"
|
||||||
|
"「任务是……收集九颗纯净的灵魂之心。这些灵魂之心分散在世界各地,每一颗都守护着一个强大的结界。我们必须找到它们,解开结界,才能获得真正的自由。但是这个过程非常危险,可能会有生命危险。你……你真的愿意和我一起冒险吗?」"))
|
||||||
|
|
||||||
|
# L5 - 接受任务
|
||||||
|
messages.append(("雪乃", False,
|
||||||
|
"(听到我毫不犹豫的回答,雪乃的眼中闪过一丝感动,但随即又被担忧所取代。她轻轻叹了口气,走到沙发旁坐下,示意我也坐下来。房间里的气氛变得凝重起来,我们都知道,接下来的旅程将会充满挑战和危险。)\n\n"
|
||||||
|
"「既然你已经决定了,那我就详细告诉你这个任务的详情。首先,你需要了解什么是'灵魂之心'。灵魂之心是人类最纯粹的情感凝聚而成的结晶,它代表着爱、勇气、希望、善良等美好的品质。每一颗灵魂之心都蕴含著强大的力量,但也吸引著无数的邪恶存在。」\n\n"
|
||||||
|
"(她从空中召唤出一张古老的地图,地图上标记着九个红色的光点,分布在世界各地的不同位置:)\n\n"
|
||||||
|
"「这九颗灵魂之心的位置分别是:第一颗在日本富士山脚下的古老神社中,由一只千年狐妖守护;第二颗在埃及金字塔的深处,被法老的诅咒所保护;第三颗在亚马逊雨林的中心,周围环绕著致命的毒雾;第四颗在北极的冰原之下,沉睡在万年寒冰之中;第五颗在撒哈拉沙漠的绿洲里,由一群沙漠精灵看守;第六颗在喜马拉雅山的顶峰,需要攀登万米高空才能到达;第七颗在大西洋的海底宫殿中,那里居住著美人鱼一族;第八颗在澳大利亚的乌鲁鲁巨石内部,被原住民的古老魔法所封印;第九颗……」\n\n"
|
||||||
|
"(她停顿了一下,眼神变得复杂:)\n\n"
|
||||||
|
"「第九颗灵魂之心,就在你的心中。」\n\n"
|
||||||
|
"(我震惊地看着她:)\n\n"
|
||||||
|
"「我的心中?这是什么意思?」\n\n"
|
||||||
|
"(雪乃解释道:)\n\n"
|
||||||
|
"「每个人的心中都潜藏着一颗灵魂之心的种子,但它需要通过考验才能觉醒。这个考验就是——在面对生死抉择时,你是否能够坚守自己的信念,是否能够为了他人而牺牲自己。只有通过了这个考验,灵魂之心才会真正觉醒。」\n\n"
|
||||||
|
"(她握住我的手,认真地看着我的眼睛:)\n\n"
|
||||||
|
"「所以,我们的任务不仅仅是收集外部的八颗灵魂之心,更重要的是,你要在旅途中不断成长,不断超越自我,最终觉醒你心中的那颗灵魂之心。只有这样,我们才能彻底解除契约,获得真正的自由。」\n\n"
|
||||||
|
"(我点了点头,心中充满了决心:)\n\n"
|
||||||
|
"「我明白了。那我们什么时候出发?」\n\n"
|
||||||
|
"(雪乃微微一笑,站起身来:)\n\n"
|
||||||
|
"「明天一早。今晚你需要好好休息,养精蓄锐。我会为你准备一些必要的装备和道具。另外……」\n\n"
|
||||||
|
"(她从怀中取出一个精致的项链,项链上挂着一颗小小的红色宝石:)\n\n"
|
||||||
|
"「这是'心灵链接'项链,戴上它,我们就可以随时感知对方的位置和状态。无论距离多远,无论遇到什么危险,我们都能彼此呼应。这是我用我自己的魔力制作的,所以请一定要妥善保管。」\n\n"
|
||||||
|
"(我接过项链,小心翼翼地戴在脖子上。宝石触碰到皮肤的瞬间,我感到一股温暖的能量流入体内,仿佛与雪乃建立了一种神秘的联系。我能感觉到她的情绪,她的想法,甚至她的记忆片段。)\n\n"
|
||||||
|
"「谢谢你,雪乃。我会珍惜它的。」\n\n"
|
||||||
|
"(雪乃的脸颊微微泛红,她转过身去,假装整理自己的裙摆:)\n\n"
|
||||||
|
"「哼,别误会了,我只是不想在任务中失去同伴而已。毕竟……毕竟你是我唯一的搭档。」\n\n"
|
||||||
|
"(看着她故作镇定的样子,我不禁笑了。三年来,她还是老样子,总是口是心非,总是用强硬的外表掩饰内心的柔软。但正是这样的她,让我深深地爱着。)\n\n"
|
||||||
|
"(夜深了,雪乃为我准备了晚餐——令人惊讶的是,她做的菜依然保持着当年的味道,那是我记忆中最熟悉的味道。我们一边吃饭,一边聊着过去的点点滴滴,仿佛回到了三年前的时光。那一刻,我觉得自己是世界上最幸福的人。)\n\n"
|
||||||
|
"(饭后,雪乃为我铺好了床, herself则坐在窗边守夜。我躺在床上,看着她月光下的侧影,心中充满了感激和爱意。我知道,明天的旅程将会很艰难,但只要和她在一起,我就无所畏惧。)\n\n"
|
||||||
|
"「晚安,雪乃。」\n\n"
|
||||||
|
"(她没有回头,只是轻轻地说了一句:)\n\n"
|
||||||
|
"「晚安……亲爱的。」\n\n"
|
||||||
|
"(那一晚,我做了个美梦。梦中,我们成功地收集了所有灵魂之心,解除了契约,重新开始了我们的生活。醒来时,我看到雪乃靠在窗边睡着了,月光洒在她的脸上,美得如同天使。我轻轻走过去,为她盖上一条毯子,然后在她的额头上印下一个温柔的吻。)\n\n"
|
||||||
|
"(新的一天,即将开始。)"))
|
||||||
|
|
||||||
|
# L6-L10 继续添加...
|
||||||
|
for i in range(6, 11):
|
||||||
|
messages.append(("我" if i % 2 == 0 else "雪乃", i % 2 == 0,
|
||||||
|
f"(第{i}层的剧情继续发展……这是一个占位符,实际使用时需要填充完整的故事内容。每个楼层都应该包含丰富的细节描写、情感表达和剧情推进,确保总长度达到2000字符以上。)\n\n" * 10))
|
||||||
|
|
||||||
|
# 添加消息
|
||||||
|
for name, is_user, mes in messages:
|
||||||
|
chat_service.add_message(role_name, chat_name, {
|
||||||
|
"name": name,
|
||||||
|
"is_user": is_user,
|
||||||
|
"mes": mes
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f" ✓ 已添加 {len(messages)} 条消息")
|
||||||
|
total_chars = sum(len(mes) for _, _, mes in messages)
|
||||||
|
print(f" ✓ 总字符数: {total_chars:,}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("✅ 测试数据创建成功!")
|
||||||
|
print("=" * 80)
|
||||||
|
print("\n提示:")
|
||||||
|
print(" - 前5层已填充完整内容")
|
||||||
|
print(" - 后5层为占位符,可根据需要补充")
|
||||||
|
print(" - 建议在UI中选择'总结模式',设置间隔为2,保留最后2层")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ 创建失败: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_test_data()
|
||||||
@@ -216,5 +216,5 @@
|
|||||||
"continue_postfix": " ",
|
"continue_postfix": " ",
|
||||||
"seed": -1,
|
"seed": -1,
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"updatedAt": 1777640905
|
"updatedAt": 1777857993
|
||||||
}
|
}
|
||||||
218
frontend/Z_INDEX_GUIDE.md
Normal file
218
frontend/Z_INDEX_GUIDE.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Z-Index 层级规范文档
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
本文档定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致、可维护。
|
||||||
|
|
||||||
|
## 🎯 设计原则
|
||||||
|
|
||||||
|
1. **分层管理**:将 z-index 划分为 5 个主要层级,每层预留充足空间
|
||||||
|
2. **语义化命名**:使用有意义的变量名,而非魔法数字
|
||||||
|
3. **统一来源**:所有 z-index 值统一定义在 `z-index.css` 中
|
||||||
|
4. **易于扩展**:每层之间至少预留 100 的空间,方便插入新层级
|
||||||
|
|
||||||
|
## 📊 层级划分
|
||||||
|
|
||||||
|
### 1️⃣ 基础层 (0-99)
|
||||||
|
用于页面背景、基础布局等底层元素
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| `--z-background` | 0 | 最底层 - 背景装饰 |
|
||||||
|
| `--z-base-content` | 1 | 基础内容层 - 普通文本、图片 |
|
||||||
|
| `--z-divider` | 10 | 分割线、边框装饰 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 页面背景渐变
|
||||||
|
- 基础卡片容器
|
||||||
|
- 列表项默认状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ 组件层 (100-999)
|
||||||
|
用于常规 UI 组件,如下拉菜单、悬浮提示等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| `--z-top-bar` | 100 | TopBar 导航栏 |
|
||||||
|
| `--z-sidebar` | 100 | 侧边栏容器 |
|
||||||
|
| `--z-dropdown-menu` | 1000 | 下拉菜单(预设操作、世界书选择) |
|
||||||
|
| `--z-sort-panel` | 1100 | 排序设置面板 |
|
||||||
|
| `--z-tooltip` | 1200 | 悬浮提示 Tooltip |
|
||||||
|
| `--z-chat-actions` | 1000 | 聊天消息操作按钮 |
|
||||||
|
| `--z-character-preview` | 1000 | 角色卡预览弹窗 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 点击按钮弹出的下拉菜单
|
||||||
|
- 鼠标悬停显示的提示信息
|
||||||
|
- 聊天消息的快捷操作按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ 弹窗层 (10000-19999)
|
||||||
|
用于模态对话框、编辑面板等需要覆盖整个页面的元素
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-modal-overlay` | 10000 | 对话框遮罩层背景 |
|
||||||
|
| `--z-modal-content` | 10100 | 对话框内容(API配置、预设保存等) |
|
||||||
|
| `--z-edit-panel-overlay` | 10200 | 世界书编辑面板遮罩层 |
|
||||||
|
| `--z-edit-panel-content` | 10300 | 世界书编辑面板内容 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- API 配置对话框
|
||||||
|
- 预设保存/编辑对话框
|
||||||
|
- 世界书条目编辑面板
|
||||||
|
- 任何需要全屏遮罩的模态窗口
|
||||||
|
|
||||||
|
**层级关系:**
|
||||||
|
```
|
||||||
|
编辑面板内容 (10300)
|
||||||
|
↓
|
||||||
|
编辑面板遮罩 (10200)
|
||||||
|
↓
|
||||||
|
对话框内容 (10100)
|
||||||
|
↓
|
||||||
|
对话框遮罩 (10000)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ 通知层 (20000-29999)
|
||||||
|
用于全局通知、Toast 提示等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-toast-container` | 20000 | Toast 通知容器 |
|
||||||
|
| `--z-toast-item` | 20100 | Toast 通知项 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 操作成功/失败的提示
|
||||||
|
- 系统通知
|
||||||
|
- 警告信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ 系统层 (30000+)
|
||||||
|
用于系统级元素,如加载动画、错误边界等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-loading-spinner` | 30000 | 全局加载动画 |
|
||||||
|
| `--z-error-boundary` | 30100 | 错误边界覆盖层 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 页面加载时的旋转动画
|
||||||
|
- 错误捕获后的全屏提示
|
||||||
|
- 系统级遮罩
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 使用指南
|
||||||
|
|
||||||
|
### CSS 中使用
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ✅ 推荐:使用 CSS 变量 */
|
||||||
|
.dropdown-menu {
|
||||||
|
z-index: var(--z-dropdown-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
z-index: var(--z-modal-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel {
|
||||||
|
z-index: var(--z-edit-panel-content);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/JavaScript 中使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 推荐:导入常量
|
||||||
|
import { Z_INDEX } from '../styles/z-index';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
zIndex: Z_INDEX.DROPDOWN_MENU,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 避免的做法
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ❌ 不要使用魔法数字 */
|
||||||
|
.dropdown-menu {
|
||||||
|
z-index: 1000; /* 难以理解,不易维护 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ❌ 不要使用过大的数值 */
|
||||||
|
.modal {
|
||||||
|
z-index: 99999; /* 不合理,可能导致层级混乱 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 添加新层级
|
||||||
|
|
||||||
|
如果需要添加新的 z-index 层级,请遵循以下步骤:
|
||||||
|
|
||||||
|
1. **确定所属层级**:根据元素类型选择合适的层级范围
|
||||||
|
2. **选择合适数值**:在该层级范围内选择一个未使用的值(预留 100 间隔)
|
||||||
|
3. **更新定义文件**:
|
||||||
|
- 在 `z-index.css` 中添加 CSS 变量
|
||||||
|
- 在 `z-index.ts` 中添加 TypeScript 常量
|
||||||
|
4. **更新文档**:在本文档中添加说明
|
||||||
|
|
||||||
|
**示例:添加一个新的工具提示层级**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* z-index.css */
|
||||||
|
:root {
|
||||||
|
--z-help-tooltip: 1300; /* 在 tooltip (1200) 之上 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// z-index.ts
|
||||||
|
export const Z_INDEX = {
|
||||||
|
// ...
|
||||||
|
HELP_TOOLTIP: 1300,
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么弹窗层从 10000 开始?
|
||||||
|
A: 为了与组件层(100-999)保持足够的距离,避免未来在组件层添加更多层级时产生冲突。
|
||||||
|
|
||||||
|
### Q: 如果两个元素都需要弹窗层怎么办?
|
||||||
|
A: 使用不同的子层级,例如:
|
||||||
|
- 第一个弹窗:`--z-modal-content` (10100)
|
||||||
|
- 第二个弹窗:`--z-modal-content + 10` (10110)
|
||||||
|
|
||||||
|
### Q: 可以在 inline style 中使用吗?
|
||||||
|
A: 可以,但推荐使用 CSS 类。如果必须使用 inline style:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ zIndex: 'var(--z-dropdown-menu)' }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **CSS 变量定义**:`frontend/src/styles/z-index.css`
|
||||||
|
- **TypeScript 常量**:`frontend/src/styles/z-index.ts`
|
||||||
|
- **全局样式引入**:`frontend/src/index.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 更新历史
|
||||||
|
|
||||||
|
| 日期 | 版本 | 更新内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 2026-05-04 | 1.0 | 初始版本,建立完整的 z-index 层级体系 |
|
||||||
@@ -114,7 +114,7 @@ function App() {
|
|||||||
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('[App] ✅ API 配置已从缓存恢复');
|
// 从缓存恢复
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[App] ❌ API 配置加载失败:', err);
|
console.error('[App] ❌ API 配置加载失败:', err);
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ const useChatBoxStore = create(
|
|||||||
if (characterData.first_mes && characterData.first_mes.trim()) {
|
if (characterData.first_mes && characterData.first_mes.trim()) {
|
||||||
// 创建临时的开场白消息(不保存到后端)
|
// 创建临时的开场白消息(不保存到后端)
|
||||||
messages = [{
|
messages = [{
|
||||||
|
id: Date.now(), // ✅ 添加唯一 ID
|
||||||
floor: 1,
|
floor: 1,
|
||||||
mes: characterData.first_mes,
|
mes: characterData.first_mes,
|
||||||
is_user: false,
|
is_user: false,
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ const useCharacterCardUIStore = create((set) => ({
|
|||||||
mes_example: character.mes_example || '',
|
mes_example: character.mes_example || '',
|
||||||
categories: character.categories || [],
|
categories: character.categories || [],
|
||||||
tags: character.tags || [],
|
tags: character.tags || [],
|
||||||
worldInfoId: character.worldInfoId || null
|
worldInfoId: character.worldInfoId || null,
|
||||||
|
// ✅ 动态表格数据
|
||||||
|
tableHeaders: character.tableHeaders || [],
|
||||||
|
tableDefaults: character.tableDefaults || {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -133,9 +133,6 @@ const usePresetStore = create(
|
|||||||
const response = await fetch(`/api/presets/${presetId}`);
|
const response = await fetch(`/api/presets/${presetId}`);
|
||||||
const presetData = await response.json();
|
const presetData = await response.json();
|
||||||
|
|
||||||
// 记录原始数据用于调试
|
|
||||||
console.log('从后端获取的预设数据:', presetData);
|
|
||||||
|
|
||||||
// 提取参数并更新状态,支持内部结构和SillyTavern结构
|
// 提取参数并更新状态,支持内部结构和SillyTavern结构
|
||||||
const parameters = {
|
const parameters = {
|
||||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||||
@@ -158,9 +155,6 @@ const usePresetStore = create(
|
|||||||
n: presetData.n !== undefined ? presetData.n : 1
|
n: presetData.n !== undefined ? presetData.n : 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// 记录映射后的参数用于调试
|
|
||||||
console.log('映射后的参数:', parameters);
|
|
||||||
|
|
||||||
// 处理预设组件 - 支持内部结构和SillyTavern结构
|
// 处理预设组件 - 支持内部结构和SillyTavern结构
|
||||||
let components = [];
|
let components = [];
|
||||||
|
|
||||||
@@ -379,6 +373,41 @@ const usePresetStore = create(
|
|||||||
newComponents.splice(toIndex, 0, movedComponent);
|
newComponents.splice(toIndex, 0, movedComponent);
|
||||||
return { promptComponents: newComponents };
|
return { promptComponents: newComponents };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 保存组件排序到后端
|
||||||
|
saveComponentOrder: async () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.selectedPreset) {
|
||||||
|
console.warn('No preset selected, cannot save order');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 提取组件 identifier 列表,按当前顺序
|
||||||
|
const componentOrder = state.promptComponents.map(component => component.identifier);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/presets/${state.selectedPreset}/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
component_order: componentOrder
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save component order');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Component order saved successfully:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save component order:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 获取当前预设的prompt_order
|
// 获取当前预设的prompt_order
|
||||||
getPromptOrder: () => {
|
getPromptOrder: () => {
|
||||||
@@ -428,27 +457,23 @@ const usePresetStore = create(
|
|||||||
console.error('[PresetStore] 恢复状态失败:', error);
|
console.error('[PresetStore] 恢复状态失败:', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 异步加载预设详情
|
||||||
if (state?.selectedPreset) {
|
setTimeout(() => {
|
||||||
console.log(`[PresetStore] 🔄 恢复上次选中的预设: ${state.selectedPreset}`);
|
usePresetStore.getState().fetchPresets().then(() => {
|
||||||
// 异步加载预设详情
|
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
|
||||||
setTimeout(() => {
|
if (state.selectedPreset) {
|
||||||
usePresetStore.getState().fetchPresets().then(() => {
|
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
|
||||||
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
|
console.error('[PresetStore] 加载预设详情失败:', err);
|
||||||
if (state.selectedPreset) {
|
});
|
||||||
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
|
}
|
||||||
console.error('[PresetStore] 加载预设详情失败:', err);
|
}).catch(err => {
|
||||||
});
|
console.error('[PresetStore] 加载预设列表失败:', err);
|
||||||
}
|
});
|
||||||
}).catch(err => {
|
}, 0);
|
||||||
console.error('[PresetStore] 加载预设列表失败:', err);
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) // ✅ 闭合 persist(
|
) // ✅ 闭合 persist(
|
||||||
);
|
);
|
||||||
|
|
||||||
export default usePresetStore;
|
export default usePresetStore;
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
import useChatBoxStore from '../Mid/ChatBoxSlice'; // ✅ 导入 ChatBoxStore (default export)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 动态表格 Store
|
* 动态表格 Store
|
||||||
* 管理 SillyTavern 风格的标签数据
|
* 管理角色卡的动态表格数据(键值对结构)
|
||||||
*/
|
*/
|
||||||
const useTableStore = create(
|
const useTableStore = create(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
// 标签数组
|
// ✅ 动态表格数据(键值对结构)
|
||||||
tags: [],
|
tableHeaders: [], // 表头数组
|
||||||
|
tableDefaults: {}, // 默认值对象
|
||||||
|
|
||||||
// 当前角色和聊天
|
// 当前角色和聊天
|
||||||
currentRole: null,
|
currentRole: null,
|
||||||
@@ -26,207 +28,92 @@ const useTableStore = create(
|
|||||||
// ==================== Actions ====================
|
// ==================== Actions ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载标签数据
|
* 加载动态表格数据
|
||||||
* @param {string} role - 角色名
|
* @param {string} role - 角色名
|
||||||
* @param {string} chat - 聊天名
|
* @param {string} chat - 聊天名
|
||||||
*/
|
*/
|
||||||
loadTags: async (role, chat) => {
|
loadTags: async (role, chat) => {
|
||||||
if (!role) {
|
if (!role) {
|
||||||
set({ tags: [], currentRole: null, currentChat: null });
|
set({
|
||||||
|
tableHeaders: [],
|
||||||
|
tableDefaults: {},
|
||||||
|
currentRole: null,
|
||||||
|
currentChat: null
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ isLoading: true, currentRole: role, currentChat: chat });
|
set({ isLoading: true, currentRole: role, currentChat: chat });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let fetchedTags = [];
|
let tableHeaders = [];
|
||||||
|
let tableDefaults = {};
|
||||||
|
|
||||||
// 优先级1:从聊天文件读取
|
// ✅ 优先级1:从聊天文件读取(如果聊天中有动态表格数据)
|
||||||
if (chat) {
|
if (chat) {
|
||||||
console.log('[TableStore] 从聊天文件读取标签数据');
|
console.log('[TableStore] 尝试从聊天文件读取动态表格数据');
|
||||||
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
|
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
|
||||||
|
|
||||||
if (!response.ok) throw new Error('获取聊天数据失败');
|
if (response.ok) {
|
||||||
|
const chatData = await response.json();
|
||||||
const chatData = await response.json();
|
const header = chatData.header || {};
|
||||||
const header = chatData.header || {};
|
|
||||||
|
// 读取聊天中的动态表格数据
|
||||||
fetchedTags = header.tags || [];
|
tableHeaders = header.tableHeaders || [];
|
||||||
|
tableDefaults = header.tableDefaults || {};
|
||||||
if (fetchedTags.length > 0) {
|
|
||||||
set({
|
if (tableHeaders.length > 0) {
|
||||||
tags: fetchedTags,
|
set({
|
||||||
lastUpdated: Date.now(),
|
tableHeaders,
|
||||||
isLoading: false
|
tableDefaults,
|
||||||
});
|
lastUpdated: Date.now(),
|
||||||
console.log(`[TableStore] ✅ 已加载 ${fetchedTags.length} 个标签`);
|
isLoading: false
|
||||||
return;
|
});
|
||||||
|
console.log(`[TableStore] ✅ 已从聊天加载动态表格: ${tableHeaders.length} 个字段`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级2:从角色卡读取(降级)
|
// ✅ 优先级2:从角色卡读取(降级)
|
||||||
console.log('[TableStore] 聊天中无标签,从角色卡读取');
|
console.log('[TableStore] 聊天中无动态表格,从角色卡读取');
|
||||||
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
|
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
|
||||||
|
|
||||||
if (!charResponse.ok) throw new Error('获取角色卡失败');
|
if (!charResponse.ok) throw new Error('获取角色卡失败');
|
||||||
|
|
||||||
const charData = await charResponse.json();
|
const charData = await charResponse.json();
|
||||||
fetchedTags = charData.tags || [];
|
tableHeaders = charData.tableHeaders || [];
|
||||||
|
tableDefaults = charData.tableDefaults || {};
|
||||||
|
|
||||||
set({
|
set({
|
||||||
tags: fetchedTags,
|
tableHeaders,
|
||||||
|
tableDefaults,
|
||||||
lastUpdated: Date.now(),
|
lastUpdated: Date.now(),
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
console.log(`[TableStore] ✅ 已从角色卡加载 ${fetchedTags.length} 个标签`);
|
console.log(`[TableStore] ✅ 已从角色卡加载动态表格: ${tableHeaders.length} 个字段`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TableStore] 获取标签数据失败:', error);
|
console.error('[TableStore] 获取动态表格数据失败:', error);
|
||||||
set({ tags: [], isLoading: false });
|
set({
|
||||||
|
tableHeaders: [],
|
||||||
|
tableDefaults: {},
|
||||||
|
isLoading: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存标签到后端(带防抖)
|
|
||||||
* @param {Array} newTags - 新的标签数组
|
|
||||||
*/
|
|
||||||
saveTags: (newTags) => {
|
|
||||||
const { currentRole, currentChat } = get();
|
|
||||||
|
|
||||||
if (!currentRole || !currentChat) {
|
|
||||||
console.warn('[TableStore] 无法保存:缺少角色或聊天信息');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除之前的定时器(如果存在)
|
|
||||||
if (get().saveTimeoutId) {
|
|
||||||
clearTimeout(get().saveTimeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟 1.5 秒后保存
|
|
||||||
const timeoutId = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/table`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ tags: newTags })
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('保存失败');
|
|
||||||
|
|
||||||
set({ lastUpdated: Date.now() });
|
|
||||||
console.log('[TableStore] ✅ 标签已保存到后端');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[TableStore] 保存标签失败:', error);
|
|
||||||
alert('保存失败: ' + error.message);
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
// 保存定时器 ID
|
|
||||||
set({ saveTimeoutId: timeoutId });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新标签(乐观更新 + 延迟保存)
|
|
||||||
* @param {Array} newTags - 新的标签数组
|
|
||||||
*/
|
|
||||||
updateTags: (newTags) => {
|
|
||||||
set({ tags: newTags });
|
|
||||||
get().saveTags(newTags);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加标签
|
|
||||||
* @param {string|string[]} tagOrTags - 单个标签或标签数组
|
|
||||||
*/
|
|
||||||
addTag: (tagOrTags) => {
|
|
||||||
const { tags } = get();
|
|
||||||
|
|
||||||
// 支持批量添加
|
|
||||||
const newTagsList = Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags];
|
|
||||||
const newTags = [...tags, ...newTagsList];
|
|
||||||
|
|
||||||
get().updateTags(newTags);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除标签
|
|
||||||
* @param {number} index - 标签索引
|
|
||||||
*/
|
|
||||||
deleteTag: (index) => {
|
|
||||||
const { tags } = get();
|
|
||||||
const newTags = tags.filter((_, i) => i !== index);
|
|
||||||
get().updateTags(newTags);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 移动标签
|
|
||||||
* @param {number} fromIndex - 起始索引
|
|
||||||
* @param {number} toIndex - 目标索引
|
|
||||||
*/
|
|
||||||
moveTag: (fromIndex, toIndex) => {
|
|
||||||
const { tags } = get();
|
|
||||||
|
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= tags.length || toIndex >= tags.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newTags = [...tags];
|
|
||||||
[newTags[fromIndex], newTags[toIndex]] = [newTags[toIndex], newTags[fromIndex]];
|
|
||||||
|
|
||||||
get().updateTags(newTags);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 左移标签
|
|
||||||
* @param {number} index - 标签索引
|
|
||||||
*/
|
|
||||||
moveLeft: (index) => {
|
|
||||||
if (index === 0) return;
|
|
||||||
get().moveTag(index, index - 1);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 右移标签
|
|
||||||
* @param {number} index - 标签索引
|
|
||||||
*/
|
|
||||||
moveRight: (index) => {
|
|
||||||
const { tags } = get();
|
|
||||||
if (index === tags.length - 1) return;
|
|
||||||
get().moveTag(index, index + 1);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑标签
|
|
||||||
* @param {number} index - 标签索引
|
|
||||||
* @param {string} newValue - 新值
|
|
||||||
*/
|
|
||||||
editTag: (index, newValue) => {
|
|
||||||
const { tags } = get();
|
|
||||||
const newTags = [...tags];
|
|
||||||
newTags[index] = newValue.trim();
|
|
||||||
get().updateTags(newTags);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空状态
|
* 清空状态
|
||||||
*/
|
*/
|
||||||
clear: () => {
|
clear: () => {
|
||||||
// 清除定时器
|
|
||||||
if (get().saveTimeoutId) {
|
|
||||||
clearTimeout(get().saveTimeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
tags: [],
|
tableHeaders: [],
|
||||||
|
tableDefaults: {},
|
||||||
currentRole: null,
|
currentRole: null,
|
||||||
currentChat: null,
|
currentChat: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
lastUpdated: null,
|
lastUpdated: null
|
||||||
saveTimeoutId: null
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -238,12 +125,54 @@ const useTableStore = create(
|
|||||||
if (!currentRole) return;
|
if (!currentRole) return;
|
||||||
|
|
||||||
await get().loadTags(currentRole, currentChat);
|
await get().loadTags(currentRole, currentChat);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新动态表格数据并保存到后端
|
||||||
|
* @param {Object} updates - 要更新的字段 { tableHeaders?, tableDefaults? }
|
||||||
|
*/
|
||||||
|
updateTableData: async (updates) => {
|
||||||
|
const { currentRole } = get();
|
||||||
|
|
||||||
|
if (!currentRole) {
|
||||||
|
console.warn('[TableStore] 无法保存:缺少角色信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观更新:先更新本地状态
|
||||||
|
set((state) => ({
|
||||||
|
tableHeaders: updates.tableHeaders ?? state.tableHeaders,
|
||||||
|
tableDefaults: updates.tableDefaults ?? state.tableDefaults,
|
||||||
|
lastUpdated: Date.now()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 异步保存到后端
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/characters/${encodeURIComponent(currentRole)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('保存失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[TableStore] ✅ 动态表格数据已保存到后端');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[TableStore] 保存动态表格数据失败:', error);
|
||||||
|
alert('保存失败: ' + error.message);
|
||||||
|
|
||||||
|
// 如果保存失败,重新加载数据以恢复状态
|
||||||
|
await get().refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'table-storage', // localStorage key
|
name: 'table-storage', // localStorage key
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
tags: state.tags,
|
tableHeaders: state.tableHeaders,
|
||||||
|
tableDefaults: state.tableDefaults,
|
||||||
currentRole: state.currentRole,
|
currentRole: state.currentRole,
|
||||||
currentChat: state.currentChat,
|
currentChat: state.currentChat,
|
||||||
lastUpdated: state.lastUpdated
|
lastUpdated: state.lastUpdated
|
||||||
@@ -252,4 +181,35 @@ const useTableStore = create(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ✅ 监听 ChatBoxStore 的变化,自动更新 TableStore 的 currentRole 和 currentChat
|
||||||
|
useChatBoxStore.subscribe(
|
||||||
|
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
||||||
|
({ role, chat }, prevState) => {
|
||||||
|
const tableStore = useTableStore.getState();
|
||||||
|
|
||||||
|
// 只有当角色或聊天真正变化时才更新
|
||||||
|
if (tableStore.currentRole !== role || tableStore.currentChat !== chat) {
|
||||||
|
const changeType = tableStore.currentRole !== role ? '角色切换' : '聊天切换';
|
||||||
|
console.log(`[TableStore] 🔄 检测到${changeType}:`, {
|
||||||
|
from: { role: tableStore.currentRole, chat: tableStore.currentChat },
|
||||||
|
to: { role, chat }
|
||||||
|
});
|
||||||
|
|
||||||
|
useTableStore.setState({
|
||||||
|
currentRole: role,
|
||||||
|
currentChat: chat
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动加载动态表格数据
|
||||||
|
if (role) {
|
||||||
|
console.log('[TableStore] 📊 开始加载动态表格数据...');
|
||||||
|
useTableStore.getState().loadTags(role, chat);
|
||||||
|
} else {
|
||||||
|
console.log('[TableStore] ⚠️ 角色为空,清空动态表格');
|
||||||
|
useTableStore.getState().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default useTableStore;
|
export default useTableStore;
|
||||||
|
|||||||
@@ -272,7 +272,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--color-bg-primary); /* 跟随主题 */
|
background-color: var(--color-bg-primary); /* 跟随主题 */
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
z-index: 10;
|
z-index: var(--z-divider); /* ✅ 基础层 - 分割线层级 */
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
padding: var(--spacing-sm);
|
padding: var(--spacing-sm);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
z-index: 100;
|
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,7 +579,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-selector-modal {
|
.chat-selector-modal {
|
||||||
|
|||||||
@@ -234,21 +234,21 @@ const ChatBox = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 渲染单条消息
|
// 渲染单条消息
|
||||||
const renderMessage = (message) => {
|
const renderMessage = (message, index) => {
|
||||||
const isUser = message.is_user;
|
const isUser = message.is_user;
|
||||||
const isEditing = editingId === message.floor;
|
const isEditing = editingId === message.floor;
|
||||||
|
|
||||||
// 判断是否为最新消息
|
// 判断是否为最新消息
|
||||||
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||||
|
|
||||||
// 根据消息类型设置显示名称
|
// 根据消息类型设置显示名称
|
||||||
const displayName = isUser ? userName : characterName;
|
const displayName = isUser ? userName : characterName;
|
||||||
|
|
||||||
// 确定当前显示的消息内容
|
// 确定当前显示的消息内容
|
||||||
let currentMes = message.mes;
|
let currentMes = message.mes;
|
||||||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||||
let currentSwipeIndex = message.swipe_id;
|
let currentSwipeIndex = message.swipe_id;
|
||||||
|
|
||||||
if (hasSwipes) {
|
if (hasSwipes) {
|
||||||
// 如果有swipes数组
|
// 如果有swipes数组
|
||||||
if (currentSwipeId[message.floor] !== undefined) {
|
if (currentSwipeId[message.floor] !== undefined) {
|
||||||
@@ -258,14 +258,17 @@ const ChatBox = () => {
|
|||||||
// 否则使用默认的swipe_id
|
// 否则使用默认的swipe_id
|
||||||
currentSwipeIndex = message.swipe_id;
|
currentSwipeIndex = message.swipe_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||||
currentMes = message.swipes[currentSwipeIndex];
|
currentMes = message.swipes[currentSwipeIndex];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 使用 id 或组合 key 确保唯一性
|
||||||
|
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
|
<div key={uniqueKey} className={`message ${isUser ? 'user' : 'ai'}`}>
|
||||||
<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>
|
||||||
@@ -517,9 +520,9 @@ const ChatBox = () => {
|
|||||||
|
|
||||||
{characterChats.length > 0 ? (
|
{characterChats.length > 0 ? (
|
||||||
<div className="chat-list">
|
<div className="chat-list">
|
||||||
{characterChats.map((chat, index) => (
|
{characterChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={chat.chat_name}
|
||||||
className="chat-option"
|
className="chat-option"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tabs {
|
.sidebar-tabs {
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ textarea.form-control {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
z-index: 1000;
|
z-index: var(--z-toast-item); /* ✅ 通知层 - Toast 项 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-success {
|
.notification-success {
|
||||||
@@ -326,7 +326,7 @@ textarea.form-control {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 激活状态的按钮 */
|
||||||
|
.action-btn.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag 筛选器 */
|
/* Tag 筛选器 */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -131,6 +138,7 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示2列 */
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示2列 */
|
||||||
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
|
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
align-content: start; /* ✅ 从上到下排列,不留空白 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-item {
|
.character-item {
|
||||||
@@ -138,7 +146,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%; /* 改为自适应宽度 */
|
width: 100%; /* 改为自适应宽度 */
|
||||||
height: 170px; /* 减小高度,从200px降到170px */
|
height: 170px; /* ✅ 固定高度,不随空间浮动 */
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -153,6 +161,19 @@
|
|||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 无图模式 - 缩小卡片 */
|
||||||
|
.character-item.no-image {
|
||||||
|
height: 80px; /* 减小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-item.no-image .character-avatar-wrapper {
|
||||||
|
display: none; /* 隐藏图片区域 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-item.no-image .character-info {
|
||||||
|
padding: 8px; /* 增加内边距 */
|
||||||
|
}
|
||||||
|
|
||||||
.character-item:hover {
|
.character-item:hover {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -304,7 +325,7 @@
|
|||||||
.floating-toolbar {
|
.floating-toolbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +378,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
margin-top: -18px; /* 使用负 margin 代替 transform,确保点击区域和视觉位置一致 */
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
@@ -368,23 +389,24 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
animation: pulse 2s infinite;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
transform: translateY(-50%) scale(1);
|
transform: scale(1);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
transform: translateY(-50%) scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-toolbar:hover .toolbar-indicator {
|
.floating-toolbar:hover .toolbar-indicator {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-50%) scale(0);
|
transform: scale(0);
|
||||||
|
pointer-events: none; /* ✅ 消失时不阻挡点击 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.indicator-icon {
|
.indicator-icon {
|
||||||
@@ -461,6 +483,18 @@
|
|||||||
border-color: #059669;
|
border-color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 复制按钮样式 */
|
||||||
|
.toolbar-btn.duplicate {
|
||||||
|
background: #8b5cf6;
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.duplicate:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-btn.delete {
|
.toolbar-btn.delete {
|
||||||
background: #ef4444;
|
background: #ef4444;
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
@@ -533,6 +567,59 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 总结配置网格布局 */
|
||||||
|
.summary-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item input[type="number"] {
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item input[type="checkbox"] {
|
||||||
|
margin-right: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-config-item textarea {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 聊天选择器弹窗 */
|
/* 聊天选择器弹窗 */
|
||||||
.chat-selector-overlay {
|
.chat-selector-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -544,7 +631,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-selector-modal {
|
.chat-selector-modal {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; //
|
|||||||
import './CharacterCard.css';
|
import './CharacterCard.css';
|
||||||
|
|
||||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||||
const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
const CharacterItem = memo(({ character, isSelected, onSelect, showImages }) => {
|
||||||
const handleImageError = useCallback((e) => {
|
const handleImageError = useCallback((e) => {
|
||||||
e.target.style.display = 'none';
|
e.target.style.display = 'none';
|
||||||
e.target.nextSibling.style.display = 'flex';
|
e.target.nextSibling.style.display = 'flex';
|
||||||
@@ -25,7 +25,7 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`character-item ${isSelected ? 'selected' : ''}`}
|
className={`character-item ${isSelected ? 'selected' : ''} ${!showImages ? 'no-image' : ''}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -66,7 +66,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
|||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// 自定义比较函数,只在必要时重新渲染
|
// 自定义比较函数,只在必要时重新渲染
|
||||||
return prevProps.character.id === nextProps.character.id &&
|
return prevProps.character.id === nextProps.character.id &&
|
||||||
prevProps.isSelected === nextProps.isSelected;
|
prevProps.isSelected === nextProps.isSelected &&
|
||||||
|
prevProps.showImages === nextProps.showImages;
|
||||||
});
|
});
|
||||||
|
|
||||||
CharacterItem.displayName = 'CharacterItem';
|
CharacterItem.displayName = 'CharacterItem';
|
||||||
@@ -108,6 +109,7 @@ const CharacterCard = () => {
|
|||||||
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
|
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
|
||||||
|
|
||||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||||
|
const [showImages, setShowImages] = useState(true); // ✅ 是否显示图片
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const clickTimeoutRef = useRef(null);
|
const clickTimeoutRef = useRef(null);
|
||||||
|
|
||||||
@@ -187,6 +189,27 @@ const CharacterCard = () => {
|
|||||||
const name = prompt('请输入角色名称:');
|
const name = prompt('请输入角色名称:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
|
// ✅ 询问历史记录模式
|
||||||
|
const historyMode = prompt(
|
||||||
|
'请选择历史记录模式:\n' +
|
||||||
|
'1. 全量模式(保留所有消息)\n' +
|
||||||
|
'2. 总结模式(定期总结历史)\n' +
|
||||||
|
'3. RAG模式(需要API配置,选择后不可取消)\n\n' +
|
||||||
|
'请输入数字 (1/2/3):'
|
||||||
|
);
|
||||||
|
|
||||||
|
let mode = 'full';
|
||||||
|
if (historyMode === '2') mode = 'summary';
|
||||||
|
else if (historyMode === '3') mode = 'rag';
|
||||||
|
|
||||||
|
if (mode === 'rag') {
|
||||||
|
const confirm = window.confirm(
|
||||||
|
'⚠️ RAG模式需要配置API,且选择后不可取消。\n' +
|
||||||
|
'是否继续?'
|
||||||
|
);
|
||||||
|
if (!confirm) return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { createCharacter } = useCharacterStore.getState();
|
const { createCharacter } = useCharacterStore.getState();
|
||||||
await createCharacter({
|
await createCharacter({
|
||||||
@@ -197,7 +220,8 @@ const CharacterCard = () => {
|
|||||||
first_mes: '',
|
first_mes: '',
|
||||||
mes_example: '',
|
mes_example: '',
|
||||||
categories: [],
|
categories: [],
|
||||||
tags: [] // ✅ 使用标签数组
|
tags: [],
|
||||||
|
historyMode: mode // ✅ 添加历史记录模式
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('创建失败:', err);
|
console.error('创建失败:', err);
|
||||||
@@ -267,6 +291,62 @@ const CharacterCard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ✅ 复制角色
|
||||||
|
const handleDuplicate = async (character) => {
|
||||||
|
if (!character) return;
|
||||||
|
|
||||||
|
const newName = prompt(`复制角色 "${character.name}"\n请输入新角色名称:`, `${character.name} - 副本`);
|
||||||
|
if (!newName) return;
|
||||||
|
|
||||||
|
// ✅ 询问历史记录模式
|
||||||
|
const historyMode = prompt(
|
||||||
|
'请选择历史记录模式:\n' +
|
||||||
|
'1. 全量模式(保留所有消息)\n' +
|
||||||
|
'2. 总结模式(定期总结历史)\n' +
|
||||||
|
'3. RAG模式(需要API配置,选择后不可取消)\n\n' +
|
||||||
|
'请输入数字 (1/2/3):'
|
||||||
|
);
|
||||||
|
|
||||||
|
let mode = 'full';
|
||||||
|
if (historyMode === '2') mode = 'summary';
|
||||||
|
else if (historyMode === '3') mode = 'rag';
|
||||||
|
|
||||||
|
if (mode === 'rag') {
|
||||||
|
const confirm = window.confirm(
|
||||||
|
'⚠️ RAG模式需要配置API,且选择后不可取消。\n' +
|
||||||
|
'是否继续?'
|
||||||
|
);
|
||||||
|
if (!confirm) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createCharacter } = useCharacterStore.getState();
|
||||||
|
|
||||||
|
// 复制角色数据(排除聊天记录)
|
||||||
|
await createCharacter({
|
||||||
|
name: newName,
|
||||||
|
description: character.description || '',
|
||||||
|
personality: character.personality || '',
|
||||||
|
scenario: character.scenario || '',
|
||||||
|
first_mes: character.first_mes || '',
|
||||||
|
mes_example: character.mes_example || '',
|
||||||
|
categories: character.categories || [],
|
||||||
|
tags: character.tags || [],
|
||||||
|
worldInfoId: character.worldInfoId || null,
|
||||||
|
tableHeaders: character.tableHeaders || [],
|
||||||
|
tableDefaults: character.tableDefaults || {},
|
||||||
|
tableMaintenancePrompt: character.tableMaintenancePrompt || null,
|
||||||
|
imageGenerationPrompt: character.imageGenerationPrompt || null,
|
||||||
|
historyMode: mode // ✅ 使用新选择的历史记录模式
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[CharacterCard] 角色已复制: ${character.name} -> ${newName}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
alert('复制失败: ' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 选择角色并切换到该角色的最后聊天记录
|
// 选择角色并切换到该角色的最后聊天记录
|
||||||
const handleSelectCharacter = useCallback(async (character) => {
|
const handleSelectCharacter = useCallback(async (character) => {
|
||||||
console.log('[CharacterCard] 点击角色:', character.name);
|
console.log('[CharacterCard] 点击角色:', character.name);
|
||||||
@@ -359,6 +439,14 @@ const CharacterCard = () => {
|
|||||||
<div className="tab-actions">
|
<div className="tab-actions">
|
||||||
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
||||||
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
||||||
|
{/* ✅ 切换图片显示 */}
|
||||||
|
<button
|
||||||
|
className={`action-btn ${showImages ? '' : 'active'}`}
|
||||||
|
onClick={() => setShowImages(!showImages)}
|
||||||
|
title={showImages ? '隐藏图片' : '显示图片'}
|
||||||
|
>
|
||||||
|
{showImages ? '🖼️' : '🚫'}
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -495,6 +583,14 @@ const CharacterCard = () => {
|
|||||||
<option value="png">🖼️ PNG</option>
|
<option value="png">🖼️ PNG</option>
|
||||||
<option value="json">📄 JSON</option>
|
<option value="json">📄 JSON</option>
|
||||||
</select>
|
</select>
|
||||||
|
{/* ✅ 复制角色按钮 */}
|
||||||
|
<button
|
||||||
|
className="toolbar-btn duplicate"
|
||||||
|
onClick={() => handleDuplicate(selectedCharacter)}
|
||||||
|
title="复制角色"
|
||||||
|
>
|
||||||
|
📋
|
||||||
|
</button>
|
||||||
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
|
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
|
||||||
📤
|
📤
|
||||||
</button>
|
</button>
|
||||||
@@ -613,7 +709,7 @@ const CharacterCard = () => {
|
|||||||
value={editForm.worldInfoId || ''}
|
value={editForm.worldInfoId || ''}
|
||||||
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
|
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
|
||||||
>
|
>
|
||||||
<option value="">不绑定</option>
|
<option key="none" value="">不绑定</option>
|
||||||
{worldBooks.map(book => (
|
{worldBooks.map(book => (
|
||||||
<option key={book.id} value={book.id}>
|
<option key={book.id} value={book.id}>
|
||||||
{book.name}
|
{book.name}
|
||||||
@@ -623,6 +719,97 @@ const CharacterCard = () => {
|
|||||||
<small className="form-hint">💡 选择后,该角色将自动加载绑定的世界书内容</small>
|
<small className="form-hint">💡 选择后,该角色将自动加载绑定的世界书内容</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ 历史记录总结设置 */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label>历史记录模式</label>
|
||||||
|
<select
|
||||||
|
value={editForm.historyMode || 'full'}
|
||||||
|
onChange={(e) => handleFormChange('historyMode', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="full">全量模式(保留所有消息)</option>
|
||||||
|
<option value="summary">总结模式(定期总结历史)</option>
|
||||||
|
<option value="rag" disabled={editForm.historyMode === 'rag'}>
|
||||||
|
RAG模式(需要API配置,选择后不可取消)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small className="form-hint">
|
||||||
|
全量模式:保留所有消息 | 总结模式:自动总结旧消息 | RAG模式:向量检索(需配置API)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ 总结配置(仅在summary模式下显示) */}
|
||||||
|
{(editForm.historyMode === 'summary' || editForm.summaryConfig) && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label>总结配置</label>
|
||||||
|
<div className="summary-config-grid">
|
||||||
|
<div className="summary-config-item">
|
||||||
|
<label>总结间隔(条)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
value={editForm.summaryConfig?.interval || 10}
|
||||||
|
onChange={(e) => {
|
||||||
|
const config = editForm.summaryConfig || {};
|
||||||
|
handleFormChange('summaryConfig', {
|
||||||
|
...config,
|
||||||
|
interval: Number(e.target.value)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="summary-config-item">
|
||||||
|
<label>保留最近楼层</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={editForm.summaryConfig?.recentFloorsToKeep || 5}
|
||||||
|
onChange={(e) => {
|
||||||
|
const config = editForm.summaryConfig || {};
|
||||||
|
handleFormChange('summaryConfig', {
|
||||||
|
...config,
|
||||||
|
recentFloorsToKeep: Number(e.target.value)
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="summary-config-item full-width">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.summaryConfig?.includeUserInput !== false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const config = editForm.summaryConfig || {};
|
||||||
|
handleFormChange('summaryConfig', {
|
||||||
|
...config,
|
||||||
|
includeUserInput: e.target.checked
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
总结时包含用户输入
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="summary-config-item full-width">
|
||||||
|
<label>总结提示词</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.summaryConfig?.summaryPrompt || '请总结以下对话内容,保留关键信息和上下文。'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const config = editForm.summaryConfig || {};
|
||||||
|
handleFormChange('summaryConfig', {
|
||||||
|
...config,
|
||||||
|
summaryPrompt: e.target.value
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
placeholder="请输入总结提示词"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small className="form-hint">
|
||||||
|
💡 总结间隔:AI回复达到此数量时触发总结 | 保留楼层:最近X条不总结
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>动态表格数据 (SillyTavern 关键字机制)</label>
|
<label>动态表格数据 (SillyTavern 关键字机制)</label>
|
||||||
<input
|
<input
|
||||||
@@ -679,6 +866,7 @@ const CharacterCard = () => {
|
|||||||
character={char}
|
character={char}
|
||||||
isSelected={selectedCharacter?.id === char.id}
|
isSelected={selectedCharacter?.id === char.id}
|
||||||
onSelect={handleSelectCharacter}
|
onSelect={handleSelectCharacter}
|
||||||
|
showImages={showImages}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -712,10 +900,10 @@ const CharacterCard = () => {
|
|||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))} // ✅ 使用 store 方法
|
onChange={(e) => setPageSize(Number(e.target.value))} // ✅ 使用 store 方法
|
||||||
>
|
>
|
||||||
<option value={8}>8条/页</option>
|
<option key="8" value={8}>8条/页</option>
|
||||||
<option value={12}>12条/页</option>
|
<option key="12" value={12}>12条/页</option>
|
||||||
<option value={20}>20条/页</option>
|
<option key="20" value={20}>20条/页</option>
|
||||||
<option value={50}>50条/页</option>
|
<option key="50" value={50}>50条/页</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
z-index: 1000;
|
z-index: var(--z-tooltip); /* ✅ 组件层 - 悬浮提示 */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transform: translate(-50%, -100%);
|
transform: translate(-50%, -100%);
|
||||||
@@ -100,15 +100,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
|
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
@@ -139,6 +137,15 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-item.delete-item {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.delete-item:hover:not(:disabled) {
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
/* 对话框 */
|
/* 对话框 */
|
||||||
.preset-save-dialog,
|
.preset-save-dialog,
|
||||||
.preset-edit-dialog,
|
.preset-edit-dialog,
|
||||||
@@ -151,7 +158,7 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-content); /* ✅ 弹窗层 - 对话框内容 */
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +289,49 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ 角色选择器 */
|
||||||
|
.component-role-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-select:hover {
|
||||||
|
border-color: var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.component-textarea {
|
.component-textarea {
|
||||||
@@ -695,6 +745,41 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 页码跳转容器 */
|
||||||
|
.page-jump-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input {
|
||||||
|
width: 50px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input:hover {
|
||||||
|
border-color: var(--color-border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-jump {
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-size-selector {
|
.page-size-selector {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const PresetPanel = () => {
|
|||||||
addComponent,
|
addComponent,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
moveComponent,
|
moveComponent,
|
||||||
|
saveComponentOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
getCurrentPagePresets,
|
getCurrentPagePresets,
|
||||||
@@ -44,12 +45,19 @@ const PresetPanel = () => {
|
|||||||
// 组件编辑状态
|
// 组件编辑状态
|
||||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||||
const [editComponentContent, setEditComponentContent] = useState('');
|
const [editComponentContent, setEditComponentContent] = useState('');
|
||||||
|
const [editComponentRole, setEditComponentRole] = useState(0); // 0=system, 1=user, 2=ai
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
// 拖拽状态
|
// 拖拽状态
|
||||||
const [draggedItem, setDraggedItem] = useState(null);
|
const [draggedItem, setDraggedItem] = useState(null);
|
||||||
const [dragOverItem, setDragOverItem] = useState(null);
|
const [dragOverItem, setDragOverItem] = useState(null);
|
||||||
|
|
||||||
|
// 下拉菜单位置
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 });
|
||||||
|
|
||||||
|
// ✅ 页码跳转输入
|
||||||
|
const [jumpPageInput, setJumpPageInput] = useState('');
|
||||||
|
|
||||||
// 点击外部关闭下拉菜单
|
// 点击外部关闭下拉菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
@@ -63,6 +71,20 @@ const PresetPanel = () => {
|
|||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [showActionsMenu]);
|
}, [showActionsMenu]);
|
||||||
|
|
||||||
|
// 计算下拉菜单位置
|
||||||
|
useEffect(() => {
|
||||||
|
if (showActionsMenu) {
|
||||||
|
const dropdownElement = document.querySelector('.actions-dropdown');
|
||||||
|
if (dropdownElement) {
|
||||||
|
const rect = dropdownElement.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
right: window.innerWidth - rect.right
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showActionsMenu]);
|
||||||
|
|
||||||
// 计算分页数据
|
// 计算分页数据
|
||||||
const currentPagePresets = getCurrentPagePresets();
|
const currentPagePresets = getCurrentPagePresets();
|
||||||
@@ -134,6 +156,116 @@ const PresetPanel = () => {
|
|||||||
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
|
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
|
||||||
// 注意:fetchPresets 已在 SideBarLeft.jsx 的 handleTabClick 中调用
|
// 注意:fetchPresets 已在 SideBarLeft.jsx 的 handleTabClick 中调用
|
||||||
|
|
||||||
|
// ✅ 处理页码跳转
|
||||||
|
const handleJumpToPage = () => {
|
||||||
|
if (!jumpPageInput) return;
|
||||||
|
|
||||||
|
const pageNum = parseInt(jumpPageInput);
|
||||||
|
|
||||||
|
// 验证页码范围
|
||||||
|
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) {
|
||||||
|
alert(`请输入 1 到 ${totalPages} 之间的页码`);
|
||||||
|
setJumpPageInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPage(pageNum);
|
||||||
|
setJumpPageInput('');
|
||||||
|
fetchPresets();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建空预设
|
||||||
|
const handleCreateEmptyPreset = () => {
|
||||||
|
setNewPresetName('');
|
||||||
|
setShowSaveDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存当前预设(覆盖现有预设)
|
||||||
|
const handleSaveCurrentPreset = async () => {
|
||||||
|
if (!selectedPreset) {
|
||||||
|
alert('请先选择一个预设');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建预设数据 - 使用内部专有结构
|
||||||
|
const presetData = {
|
||||||
|
// GenerationPreset 部分 - 采样参数
|
||||||
|
id: selectedPreset,
|
||||||
|
name: presets.find(p => p.id === selectedPreset)?.name || selectedPreset,
|
||||||
|
temperature: parameters.temperature,
|
||||||
|
topP: parameters.top_p,
|
||||||
|
topK: parameters.top_k,
|
||||||
|
frequencyPenalty: parameters.frequency_penalty,
|
||||||
|
presencePenalty: parameters.presence_penalty,
|
||||||
|
maxLength: parameters.max_tokens,
|
||||||
|
isDefault: false,
|
||||||
|
|
||||||
|
// PromptPresetView 部分 - prompt组件列表
|
||||||
|
characterId: 'global',
|
||||||
|
entries: promptComponents.map((component, index) => ({
|
||||||
|
identifier: component.identifier,
|
||||||
|
name: component.name,
|
||||||
|
enabled: component.enabled !== false,
|
||||||
|
content: component.content || '',
|
||||||
|
order: index,
|
||||||
|
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'ai',
|
||||||
|
tokenCount: component.content ? component.content.length : 0,
|
||||||
|
isSystemNode: component.marker || false
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送到后端更新
|
||||||
|
const response = await fetch(`/api/presets/${selectedPreset}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(presetData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update preset');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('预设已保存!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存预设失败:', error);
|
||||||
|
alert('保存预设失败: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除预设
|
||||||
|
const handleDeletePreset = async (presetId) => {
|
||||||
|
if (!confirm(`确定要删除预设 "${presets.find(p => p.id === presetId)?.name}" 吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/presets/${presetId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete preset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果删除的是当前选中的预设,清空选择
|
||||||
|
if (selectedPreset === presetId) {
|
||||||
|
setSelectedPreset('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载预设列表
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchPresets();
|
||||||
|
|
||||||
|
alert('预设已删除!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除预设失败:', error);
|
||||||
|
alert('删除预设失败: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 保存当前设置为预设
|
// 保存当前设置为预设
|
||||||
const handleSavePreset = async () => {
|
const handleSavePreset = async () => {
|
||||||
if (newPresetName.trim()) {
|
if (newPresetName.trim()) {
|
||||||
@@ -398,11 +530,19 @@ const PresetPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 放置
|
// 放置
|
||||||
const handleDrop = (e, index) => {
|
const handleDrop = async (e, index) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedItem === null || draggedItem === index) return;
|
if (draggedItem === null || draggedItem === index) return;
|
||||||
|
|
||||||
moveComponent(draggedItem, index);
|
moveComponent(draggedItem, index);
|
||||||
|
|
||||||
|
// ✅ 拖拽后保存排序到后端
|
||||||
|
try {
|
||||||
|
await saveComponentOrder();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save component order:', error);
|
||||||
|
alert('保存排序失败: ' + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
setDraggedItem(null);
|
setDraggedItem(null);
|
||||||
setDragOverItem(null);
|
setDragOverItem(null);
|
||||||
@@ -448,34 +588,35 @@ const PresetPanel = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showActionsMenu && (
|
{showActionsMenu && (
|
||||||
<div className="dropdown-menu">
|
<div
|
||||||
|
className="dropdown-menu"
|
||||||
|
style={{
|
||||||
|
top: `${dropdownPosition.top}px`,
|
||||||
|
right: `${dropdownPosition.right}px`
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
title="将当前的参数设置和组件配置保存为新的预设"
|
title="创建一个全新的空白预设"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowSaveDialog(true);
|
handleCreateEmptyPreset();
|
||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
💾 保存为新预设
|
➕ 新建空预设
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
disabled={!selectedPreset}
|
disabled={!selectedPreset}
|
||||||
title="修改当前选中预设的名称"
|
title="保存当前的参数和组件配置到当前预设"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedPreset) {
|
if (selectedPreset) {
|
||||||
const preset = presets.find(p => p.id === selectedPreset);
|
handleSaveCurrentPreset();
|
||||||
if (preset) {
|
|
||||||
setEditPresetId(selectedPreset);
|
|
||||||
setEditPresetName(preset.name);
|
|
||||||
setShowEditDialog(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✏️ 编辑名称
|
💾 保存当前预设
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
@@ -485,7 +626,7 @@ const PresetPanel = () => {
|
|||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📥 导入预设
|
📥 导入
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
@@ -496,7 +637,20 @@ const PresetPanel = () => {
|
|||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📤 导出预设
|
📤 导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="dropdown-item delete-item"
|
||||||
|
disabled={!selectedPreset}
|
||||||
|
title="删除当前选中的预设"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedPreset) {
|
||||||
|
handleDeletePreset(selectedPreset);
|
||||||
|
}
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗑️ 删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -561,6 +715,22 @@ const PresetPanel = () => {
|
|||||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="dialog-content">
|
<div className="dialog-content">
|
||||||
|
{/* ✅ 角色选择器 */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="component-role-selector">
|
||||||
|
<label className="role-label">角色:</label>
|
||||||
|
<select
|
||||||
|
className="role-select"
|
||||||
|
value={editComponentRole}
|
||||||
|
onChange={(e) => setEditComponentRole(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={0}>System (系统)</option>
|
||||||
|
<option value={1}>User (用户)</option>
|
||||||
|
<option value={2}>AI (助手)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={editComponentContent}
|
value={editComponentContent}
|
||||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||||
@@ -818,10 +988,8 @@ const PresetPanel = () => {
|
|||||||
draggable={!component.marker}
|
draggable={!component.marker}
|
||||||
onDragStart={(e) => handleDragStart(e, index)}
|
onDragStart={(e) => handleDragStart(e, index)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
e.preventDefault();
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="component-header">
|
<div className="component-header">
|
||||||
<div className="component-controls">
|
<div className="component-controls">
|
||||||
@@ -870,7 +1038,10 @@ const PresetPanel = () => {
|
|||||||
{/* 最后一个拖拽指示器 - 在列表末尾 */}
|
{/* 最后一个拖拽指示器 - 在列表末尾 */}
|
||||||
<div
|
<div
|
||||||
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
|
className={`drag-indicator ${dragOverItem === promptComponents.length ? 'visible' : ''}`}
|
||||||
onDragOver={(e) => handleDragOver(e, promptComponents.length)}
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDragOver(e, promptComponents.length);
|
||||||
|
}}
|
||||||
onDrop={(e) => handleDrop(e, promptComponents.length)}
|
onDrop={(e) => handleDrop(e, promptComponents.length)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -889,6 +1060,31 @@ const PresetPanel = () => {
|
|||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* ✅ 页码跳转输入框 */}
|
||||||
|
<div className="page-jump-container">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="page-jump-input"
|
||||||
|
value={jumpPageInput}
|
||||||
|
onChange={(e) => setJumpPageInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleJumpToPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="页码"
|
||||||
|
min="1"
|
||||||
|
max={totalPages}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="pagination-btn btn-jump"
|
||||||
|
onClick={handleJumpToPage}
|
||||||
|
disabled={!jumpPageInput}
|
||||||
|
>
|
||||||
|
跳转
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="pagination-info">
|
<span className="pagination-info">
|
||||||
第 {currentPage} / {totalPages} 页
|
第 {currentPage} / {totalPages} 页
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,94 +1,27 @@
|
|||||||
|
/* ==================== SillyTavern 风格系统设置 ==================== */
|
||||||
|
|
||||||
.settings-container {
|
.settings-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 16px;
|
background: var(--color-bg-primary);
|
||||||
background: var(--bg-primary);
|
color: var(--color-text-primary);
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-header {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-bottom: 12px;
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 消息提示 */
|
/* 消息提示 */
|
||||||
.message {
|
.message {
|
||||||
padding: 12px;
|
margin: 8px;
|
||||||
margin-bottom: 16px;
|
padding: 10px 14px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: slideDown 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.success {
|
@keyframes slideDown {
|
||||||
background: rgba(76, 175, 80, 0.1);
|
|
||||||
border: 1px solid #4caf50;
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.error {
|
|
||||||
background: rgba(244, 67, 54, 0.1);
|
|
||||||
border: 1px solid #f44336;
|
|
||||||
color: #f44336;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分页标签 */
|
|
||||||
.settings-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
color: var(--accent-color);
|
|
||||||
border-bottom-color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 内容区域 */
|
|
||||||
.settings-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 设置区块 */
|
|
||||||
.settings-section {
|
|
||||||
animation: fadeIn 0.3s ease-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -96,135 +29,313 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section h3 {
|
.message.success {
|
||||||
margin: 0 0 8px 0;
|
background: rgba(76, 175, 80, 0.15);
|
||||||
font-size: 16px;
|
border: 1px solid #4caf50;
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(244, 67, 54, 0.15);
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
color: #ef5350;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 左右分栏布局 ==================== */
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧边栏 - 垂直标签 */
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 180px;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab:hover {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tab.active {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
color: var(--color-accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-description {
|
.tab-icon {
|
||||||
margin: 0 0 16px 0;
|
font-size: 16px;
|
||||||
font-size: 13px;
|
line-height: 1;
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 设置项 */
|
.tab-label {
|
||||||
.setting-item {
|
flex: 1;
|
||||||
margin-bottom: 20px;
|
}
|
||||||
|
|
||||||
|
/* 右侧主内容区 */
|
||||||
|
.settings-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item label {
|
.loading {
|
||||||
display: block;
|
text-align: center;
|
||||||
margin-bottom: 8px;
|
padding: 60px 20px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item input[type="text"],
|
/* ==================== 设置面板 ==================== */
|
||||||
.setting-item input[type="file"] {
|
.settings-panel {
|
||||||
|
max-width: 800px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 设置分组 ==================== */
|
||||||
|
.settings-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设置行 */
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-label {
|
||||||
|
min-width: 120px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框 */
|
||||||
|
.text-input,
|
||||||
|
.file-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
color: var(--text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item input[type="text"]:focus {
|
.text-input:focus,
|
||||||
|
.file-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent-color);
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item small {
|
.hint-text {
|
||||||
display: block;
|
font-size: 11px;
|
||||||
margin-top: 6px;
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item code {
|
/* 路径代码 */
|
||||||
display: block;
|
.path-code {
|
||||||
padding: 8px 12px;
|
display: inline-block;
|
||||||
background: var(--bg-secondary);
|
padding: 6px 10px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--accent-color);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文件结构列表 */
|
/* 文件结构列表 */
|
||||||
.file-structure {
|
.file-structure-list {
|
||||||
margin: 8px 0 0 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-structure li {
|
.file-structure-list li {
|
||||||
padding: 4px 0;
|
padding: 6px 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 预览框 */
|
.folder-icon {
|
||||||
.preview-box {
|
font-size: 14px;
|
||||||
margin-top: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-box .original,
|
/* 预览容器 */
|
||||||
.preview-box .processed {
|
.preview-container {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-box .original:last-child,
|
.preview-section:last-child {
|
||||||
.preview-box .processed:last-child {
|
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-box strong {
|
.preview-label {
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-box p {
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮 */
|
.preview-code {
|
||||||
.btn-primary {
|
padding: 10px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-code.processed {
|
||||||
|
background: rgba(76, 175, 80, 0.05);
|
||||||
|
border-color: rgba(76, 175, 80, 0.3);
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮组 */
|
||||||
|
.actions-group {
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: var(--accent-color);
|
background: var(--color-accent);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.15s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-save:hover:not(:disabled) {
|
||||||
background: var(--accent-hover);
|
background: var(--color-accent-hover, #5a67d8);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:disabled {
|
.btn-save:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,11 +81,6 @@ const Settings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-container">
|
<div className="settings-container">
|
||||||
{/* 标题 */}
|
|
||||||
<div className="settings-header">
|
|
||||||
<h2>系统设置</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 消息提示 */}
|
{/* 消息提示 */}
|
||||||
{message.text && (
|
{message.text && (
|
||||||
<div className={`message ${message.type}`}>
|
<div className={`message ${message.type}`}>
|
||||||
@@ -93,171 +88,216 @@ const Settings = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 分页标签 */}
|
{/* SillyTavern 风格:左右分栏布局 */}
|
||||||
<div className="settings-tabs">
|
<div className="settings-layout">
|
||||||
<button
|
{/* 左侧:垂直标签栏 */}
|
||||||
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
|
<div className="settings-sidebar">
|
||||||
onClick={() => setActiveTab('regex')}
|
<button
|
||||||
>
|
className={`sidebar-tab ${activeTab === 'regex' ? 'active' : ''}`}
|
||||||
正则规则
|
onClick={() => setActiveTab('regex')}
|
||||||
</button>
|
>
|
||||||
<button
|
<span className="tab-icon">📝</span>
|
||||||
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
|
<span className="tab-label">正则规则</span>
|
||||||
onClick={() => setActiveTab('thinking')}
|
</button>
|
||||||
>
|
<button
|
||||||
思考标签
|
className={`sidebar-tab ${activeTab === 'thinking' ? 'active' : ''}`}
|
||||||
</button>
|
onClick={() => setActiveTab('thinking')}
|
||||||
<button
|
>
|
||||||
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
|
<span className="tab-icon">💭</span>
|
||||||
onClick={() => setActiveTab('general')}
|
<span className="tab-label">思考标签</span>
|
||||||
>
|
</button>
|
||||||
通用设置
|
<button
|
||||||
</button>
|
className={`sidebar-tab ${activeTab === 'general' ? 'active' : ''}`}
|
||||||
</div>
|
onClick={() => setActiveTab('general')}
|
||||||
|
>
|
||||||
|
<span className="tab-icon">⚙️</span>
|
||||||
|
<span className="tab-label">通用设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 右侧:内容区域 */}
|
||||||
<div className="settings-content">
|
<div className="settings-main">
|
||||||
{loading && <div className="loading">加载中...</div>}
|
{loading && <div className="loading">加载中...</div>}
|
||||||
|
|
||||||
{/* 正则规则设置 */}
|
{/* 正则规则设置 */}
|
||||||
{activeTab === 'regex' && (
|
{activeTab === 'regex' && (
|
||||||
<div className="settings-section">
|
<div className="settings-panel">
|
||||||
<h3>正则规则管理</h3>
|
<div className="panel-header">
|
||||||
<p className="section-description">
|
<h3>📝 正则规则管理</h3>
|
||||||
管理文本处理规则,支持 SillyTavern 格式导入
|
<p className="panel-description">
|
||||||
</p>
|
管理文本处理规则,支持 SillyTavern 格式导入
|
||||||
|
</p>
|
||||||
<div className="setting-item">
|
</div>
|
||||||
<label>规则文件位置</label>
|
|
||||||
<code>data/regex/</code>
|
<div className="settings-group">
|
||||||
<ul className="file-structure">
|
<div className="group-title">规则文件结构</div>
|
||||||
<li>📁 global/ - 全局规则</li>
|
<div className="setting-row">
|
||||||
<li>📁 characters/ - 角色卡规则</li>
|
<div className="row-label">存储位置</div>
|
||||||
<li>📁 presets/ - 预设规则</li>
|
<div className="row-content">
|
||||||
</ul>
|
<code className="path-code">data/regex/</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-item">
|
|
||||||
<label>导入规则</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/regex/import', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
|
||||||
showMessage('success', data.message);
|
|
||||||
} else {
|
|
||||||
showMessage('error', '导入失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage('error', '导入失败');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small>支持 SillyTavern 格式的规则文件</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-item">
|
|
||||||
<button className="btn-primary" onClick={() => window.open('/api/regex/export/global', '_blank')}>
|
|
||||||
导出全局规则
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 思考标签配置 */}
|
|
||||||
{activeTab === 'thinking' && (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h3>思考标签配置</h3>
|
|
||||||
<p className="section-description">
|
|
||||||
配置 AI 推理/思考内容的标记标签
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="setting-item">
|
|
||||||
<label htmlFor="thinking-prefix">前缀</label>
|
|
||||||
<input
|
|
||||||
id="thinking-prefix"
|
|
||||||
type="text"
|
|
||||||
value={settings.thinkingTagPrefix}
|
|
||||||
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
|
|
||||||
placeholder="<thinking>"
|
|
||||||
/>
|
|
||||||
<small>默认值:<thinking></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-item">
|
|
||||||
<label htmlFor="thinking-suffix">后缀</label>
|
|
||||||
<input
|
|
||||||
id="thinking-suffix"
|
|
||||||
type="text"
|
|
||||||
value={settings.thinkingTagSuffix}
|
|
||||||
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
|
||||||
placeholder="</thinking>"
|
|
||||||
/>
|
|
||||||
<small>默认值:</thinking></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-item preview">
|
|
||||||
<label>预览效果</label>
|
|
||||||
<div className="preview-box">
|
|
||||||
<div className="original">
|
|
||||||
<strong>原始内容:</strong>
|
|
||||||
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="processed">
|
<ul className="file-structure-list">
|
||||||
<strong>处理后:</strong>
|
<li><span className="folder-icon">📁</span> global/ - 全局规则</li>
|
||||||
<p>这是回复 继续</p>
|
<li><span className="folder-icon">📁</span> characters/ - 角色卡规则</li>
|
||||||
|
<li><span className="folder-icon">📁</span> presets/ - 预设规则</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">导入导出</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="row-label">导入规则文件</div>
|
||||||
|
<div className="row-content">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="file-input"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/regex/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('success', data.message);
|
||||||
|
} else {
|
||||||
|
showMessage('error', '导入失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage('error', '导入失败');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small className="hint-text">支持 SillyTavern 格式的规则文件</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="row-label">导出全局规则</div>
|
||||||
|
<div className="row-content">
|
||||||
|
<button
|
||||||
|
className="btn-action"
|
||||||
|
onClick={() => window.open('/api/regex/export/global', '_blank')}
|
||||||
|
>
|
||||||
|
📥 导出 JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="setting-item">
|
{/* 思考标签配置 */}
|
||||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
{activeTab === 'thinking' && (
|
||||||
{loading ? '保存中...' : '保存设置'}
|
<div className="settings-panel">
|
||||||
</button>
|
<div className="panel-header">
|
||||||
</div>
|
<h3>💭 思考标签配置</h3>
|
||||||
</div>
|
<p className="panel-description">
|
||||||
)}
|
配置 AI 推理/思考内容的标记标签
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">标签设置</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="row-label">前缀</div>
|
||||||
|
<div className="row-content">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.thinkingTagPrefix}
|
||||||
|
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
|
||||||
|
placeholder="<thinking>"
|
||||||
|
className="text-input"
|
||||||
|
/>
|
||||||
|
<small className="hint-text">默认值:<thinking></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 通用设置 */}
|
<div className="setting-row">
|
||||||
{activeTab === 'general' && (
|
<div className="row-label">后缀</div>
|
||||||
<div className="settings-section">
|
<div className="row-content">
|
||||||
<h3>通用设置</h3>
|
<input
|
||||||
<p className="section-description">
|
type="text"
|
||||||
其他系统级配置
|
value={settings.thinkingTagSuffix}
|
||||||
</p>
|
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
||||||
|
placeholder="</thinking>"
|
||||||
<div className="setting-item">
|
className="text-input"
|
||||||
<label htmlFor="current-preset">当前预设</label>
|
/>
|
||||||
<input
|
<small className="hint-text">默认值:</thinking></small>
|
||||||
id="current-preset"
|
</div>
|
||||||
type="text"
|
</div>
|
||||||
value={settings.currentPresetName || ''}
|
</div>
|
||||||
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
|
|
||||||
placeholder="未选择"
|
|
||||||
/>
|
|
||||||
<small>影响全局正则规则的启用</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-item">
|
<div className="settings-group">
|
||||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
<div className="group-title">效果预览</div>
|
||||||
{loading ? '保存中...' : '保存设置'}
|
<div className="preview-container">
|
||||||
</button>
|
<div className="preview-section">
|
||||||
|
<div className="preview-label">原始内容:</div>
|
||||||
|
<div className="preview-code">
|
||||||
|
这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="preview-section">
|
||||||
|
<div className="preview-label">处理后:</div>
|
||||||
|
<div className="preview-code processed">
|
||||||
|
这是回复 继续
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group actions-group">
|
||||||
|
<button className="btn-save" onClick={saveSettings} disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '💾 保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* 通用设置 */}
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div className="settings-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>⚙️ 通用设置</h3>
|
||||||
|
<p className="panel-description">
|
||||||
|
其他系统级配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group">
|
||||||
|
<div className="group-title">预设配置</div>
|
||||||
|
<div className="setting-row">
|
||||||
|
<div className="row-label">当前预设</div>
|
||||||
|
<div className="row-content">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.currentPresetName || ''}
|
||||||
|
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
|
||||||
|
placeholder="未选择"
|
||||||
|
className="text-input"
|
||||||
|
/>
|
||||||
|
<small className="hint-text">影响全局正则规则的启用</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-group actions-group">
|
||||||
|
<button className="btn-save" onClick={saveSettings} disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '💾 保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 8px; /* 与全局世界书保持间距 */
|
margin-top: 8px; /* 与全局世界书保持间距 */
|
||||||
position: relative; /* 确保正常文档流 */
|
position: relative; /* 确保正常文档流 */
|
||||||
z-index: 1; /* 降低层级,不遮挡其他元素 */
|
z-index: var(--z-base-content); /* ✅ 基础内容层 */
|
||||||
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
|
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
|
||||||
min-height: 307px;
|
min-height: 307px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -258,17 +258,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +292,33 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 世界书下拉菜单项内容布局 */
|
||||||
|
.dropdown-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-indicator {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== 条目列表区域 ==================== */
|
/* ==================== 条目列表区域 ==================== */
|
||||||
.entries-container {
|
.entries-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -321,6 +345,41 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 页码跳转容器 */
|
||||||
|
.page-jump-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input {
|
||||||
|
width: 50px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-jump-input:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-jump {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-size-select {
|
.page-size-select {
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -634,12 +693,13 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.3); /* ✅ 降低透明度,不使用模糊 */
|
||||||
backdrop-filter: blur(4px);
|
/* backdrop-filter: blur(4px); */ /* ✅ 移除模糊效果,允许操作旁边区域 */
|
||||||
z-index: 9998;
|
z-index: var(--z-edit-panel-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
pointer-events: none; /* ✅ 允许点击穿透到下层元素 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-panel-overlay.open {
|
.edit-panel-overlay.open {
|
||||||
@@ -657,7 +717,7 @@
|
|||||||
height: 85vh;
|
height: 85vh;
|
||||||
max-height: 900px;
|
max-height: 900px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
z-index: 9999;
|
z-index: var(--z-edit-panel-content); /* ✅ 弹窗层 - 编辑面板 */
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -764,6 +824,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch; /* ✅ 确保所有子元素高度一致 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
@@ -775,22 +836,30 @@
|
|||||||
.form-group.compact {
|
.form-group.compact {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
/* ✅ 统一输入框和选择框的高度 */
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
padding: 10px 12px;
|
padding: 9px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
height: 38px; /* ✅ 固定高度 */
|
||||||
|
line-height: 1.4;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:hover {
|
.form-input:hover {
|
||||||
@@ -804,6 +873,16 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* select 特殊优化 */
|
||||||
|
select.form-input {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.content-textarea {
|
.content-textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
@@ -817,6 +896,39 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-selector {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-selector .form-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger-config-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 2px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyword-config {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ✅ 编辑面板底部操作栏 */
|
/* ✅ 编辑面板底部操作栏 */
|
||||||
@@ -847,20 +959,118 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trigger-selector {
|
/* ✅ 条件触发配置样式 */
|
||||||
flex: 1;
|
.condition-config {
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-config-panel {
|
|
||||||
flex: 2;
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword-config {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 条件块样式 */
|
||||||
|
.condition-block {
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-block:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-label::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields {
|
||||||
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields .form-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields select.form-input {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 95px;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 条件预览 */
|
||||||
|
.condition-preview {
|
||||||
|
background: linear-gradient(135deg, var(--color-accent-ultra-light) 0%, rgba(102, 126, 234, 0.05) 100%);
|
||||||
|
border: 1px solid var(--color-accent-light);
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label::before {
|
||||||
|
content: '📋';
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-code {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 分页控件样式 ==================== */
|
/* ==================== 分页控件样式 ==================== */
|
||||||
@@ -927,15 +1137,13 @@
|
|||||||
|
|
||||||
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
|
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
|
||||||
.sort-dropdown-menu {
|
.sort-dropdown-menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: var(--color-bg-elevated);
|
background: var(--color-bg-elevated);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
z-index: 1100;
|
z-index: var(--z-sort-panel); /* ✅ 组件层 - 排序面板 */
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
|
import ReactDOM from 'react-dom'; // ✅ 用于 Portal
|
||||||
import './WorldBook.css';
|
import './WorldBook.css';
|
||||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
|
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
|
||||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
||||||
@@ -76,25 +77,34 @@ const WorldBook = () => {
|
|||||||
// 分页状态
|
// 分页状态
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20); // 默认每页20条
|
const [pageSize, setPageSize] = useState(20); // 默认每页20条
|
||||||
|
const [jumpPageInput, setJumpPageInput] = useState(''); // ✅ 页码跳转输入
|
||||||
|
|
||||||
// ✅ 排序状态
|
// ✅ 排序状态
|
||||||
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
|
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
|
||||||
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
|
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
|
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
|
||||||
|
|
||||||
|
// 下拉菜单位置
|
||||||
|
const [sortDropdownPosition, setSortDropdownPosition] = useState({ top: 0, right: 0 });
|
||||||
|
const [worldbookDropdownPosition, setWorldbookDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
// ✅ 点击外部关闭排序下拉菜单
|
// ✅ 点击外部关闭排序下拉菜单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
|
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
|
||||||
setShowSortMenu(false);
|
setShowSortMenu(false);
|
||||||
}
|
}
|
||||||
|
// ✅ 点击外部关闭世界书下拉菜单
|
||||||
|
if (showWorldBookDropdown && !event.target.closest('.worldbook-dropdown-container')) {
|
||||||
|
setShowWorldBookDropdown(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [showSortMenu]);
|
}, [showSortMenu, showWorldBookDropdown]);
|
||||||
|
|
||||||
// 获取当前激活的分页
|
// 获取当前激活的分页
|
||||||
const { activeTab } = useSideBarLeftStore();
|
const { activeTab } = useSideBarLeftStore();
|
||||||
@@ -267,6 +277,7 @@ const WorldBook = () => {
|
|||||||
|
|
||||||
const handleEntryClick = (entry) => {
|
const handleEntryClick = (entry) => {
|
||||||
setCurrentEntry(entry);
|
setCurrentEntry(entry);
|
||||||
|
setLocalEntry(entry); // ✅ 设置本地状态以在编辑面板中显示
|
||||||
setShowEditPanel(true);
|
setShowEditPanel(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -287,6 +298,27 @@ const WorldBook = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ✅ 处理列表中条目的快速更新(不依赖 currentEntry)
|
||||||
|
const handleEntryQuickUpdate = async (entry, field, value) => {
|
||||||
|
if (!currentWorldBook) return;
|
||||||
|
|
||||||
|
const updatedEntry = { ...entry, [field]: value };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateWorldBookEntry(currentWorldBook.name, entry.uid, updatedEntry);
|
||||||
|
// 如果当前正在编辑这个条目,也更新 currentEntry 和 localEntry
|
||||||
|
if (currentEntry?.uid === entry.uid) {
|
||||||
|
setCurrentEntry(updatedEntry);
|
||||||
|
setLocalEntry(updatedEntry);
|
||||||
|
}
|
||||||
|
// 更新成功后刷新当前页
|
||||||
|
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||||
|
console.log(`[WorldBook] ✅ 快速更新 ${field} 成功`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[WorldBook] ❌ 快速更新 ${field} 失败:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理页码变化
|
// 处理页码变化
|
||||||
const handlePageChange = async (newPage) => {
|
const handlePageChange = async (newPage) => {
|
||||||
if (!currentWorldBook) return;
|
if (!currentWorldBook) return;
|
||||||
@@ -310,6 +342,29 @@ const WorldBook = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ✅ 处理页码跳转
|
||||||
|
const handleJumpToPage = async () => {
|
||||||
|
if (!currentWorldBook || !jumpPageInput) return;
|
||||||
|
|
||||||
|
const pageNum = parseInt(jumpPageInput);
|
||||||
|
const totalPages = entriesPagination.total_pages;
|
||||||
|
|
||||||
|
// 验证页码范围
|
||||||
|
if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) {
|
||||||
|
alert(`请输入 1 到 ${totalPages} 之间的页码`);
|
||||||
|
setJumpPageInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPage(pageNum);
|
||||||
|
setJumpPageInput('');
|
||||||
|
try {
|
||||||
|
await fetchWorldBookEntries(currentWorldBook.name, pageNum, pageSize);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载世界书条目失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ✅ 性能优化:防抖保存,500ms后自动保存
|
// ✅ 性能优化:防抖保存,500ms后自动保存
|
||||||
const debouncedSave = React.useCallback((updatedEntry) => {
|
const debouncedSave = React.useCallback((updatedEntry) => {
|
||||||
if (saveTimeoutRef.current) {
|
if (saveTimeoutRef.current) {
|
||||||
@@ -710,14 +765,33 @@ const WorldBook = () => {
|
|||||||
|
|
||||||
{/* 世界书选择区域 */}
|
{/* 世界书选择区域 */}
|
||||||
<div className="worldbook-selector">
|
<div className="worldbook-selector">
|
||||||
<div className="dropdown" style={{flex: 1}}>
|
<div className="dropdown worldbook-dropdown-container" style={{flex: 1, position: 'relative'}}>
|
||||||
<button className="dropdown-btn"
|
<button className="dropdown-btn"
|
||||||
onClick={() => setShowWorldBookDropdown(!showWorldBookDropdown)}>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// ✅ 计算下拉菜单位置
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setWorldbookDropdownPosition({
|
||||||
|
top: rect.bottom + 4, // 按钮下方 4px
|
||||||
|
left: rect.left
|
||||||
|
});
|
||||||
|
setShowWorldBookDropdown(!showWorldBookDropdown);
|
||||||
|
}}>
|
||||||
{currentWorldBook ? currentWorldBook.name : '选择世界书'}
|
{currentWorldBook ? currentWorldBook.name : '选择世界书'}
|
||||||
<span>▼</span>
|
<span>▼</span>
|
||||||
</button>
|
</button>
|
||||||
{showWorldBookDropdown && (
|
{showWorldBookDropdown && (
|
||||||
<div className="dropdown-menu">
|
<div
|
||||||
|
className="dropdown-menu"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${worldbookDropdownPosition.top}px`,
|
||||||
|
left: `${worldbookDropdownPosition.left}px`,
|
||||||
|
minWidth: '200px',
|
||||||
|
maxWidth: '300px',
|
||||||
|
zIndex: 1000
|
||||||
|
}}
|
||||||
|
>
|
||||||
{worldBooks.map(book => (
|
{worldBooks.map(book => (
|
||||||
<div
|
<div
|
||||||
key={book.name}
|
key={book.name}
|
||||||
@@ -794,7 +868,7 @@ const WorldBook = () => {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEntryUpdate('position', parseInt(e.target.value));
|
handleEntryQuickUpdate(entry, 'position', parseInt(e.target.value));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[0, 1, 2, 3, 4, 5].map(pos => {
|
{[0, 1, 2, 3, 4, 5].map(pos => {
|
||||||
@@ -813,7 +887,8 @@ const WorldBook = () => {
|
|||||||
title={isEnabled ? '已启用' : '已禁用'}
|
title={isEnabled ? '已启用' : '已禁用'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEntryUpdate('disable', !isEnabled);
|
// ✅ 直接更新条目,使用 entry 而不是 currentEntry
|
||||||
|
handleEntryQuickUpdate(entry, 'disable', !isEnabled);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +946,8 @@ const WorldBook = () => {
|
|||||||
|
|
||||||
{/* 编辑面板遮罩层 */}
|
{/* 编辑面板遮罩层 */}
|
||||||
<div className={`edit-panel-overlay ${showEditPanel ? 'open' : ''}`}
|
<div className={`edit-panel-overlay ${showEditPanel ? 'open' : ''}`}
|
||||||
onClick={() => setShowEditPanel(false)}/>
|
/* ✅ 移除 onClick,允许点击穿透到下层元素(动态表格等) */
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 编辑面板 */}
|
{/* 编辑面板 */}
|
||||||
{showEditPanel && localEntry && (
|
{showEditPanel && localEntry && (
|
||||||
@@ -879,7 +955,11 @@ const WorldBook = () => {
|
|||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="edit-panel-header">
|
<div className="edit-panel-header">
|
||||||
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
|
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
|
||||||
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
|
<button className="close-btn" onClick={() => {
|
||||||
|
setShowEditPanel(false);
|
||||||
|
setLocalEntry(null); // ✅ 清空本地状态
|
||||||
|
setCurrentEntry(null); // ✅ 清空当前条目
|
||||||
|
}}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1183,12 +1263,12 @@ const WorldBook = () => {
|
|||||||
|
|
||||||
{activeTriggerStrategy === 'condition' && (
|
{activeTriggerStrategy === 'condition' && (
|
||||||
<div className="condition-config">
|
<div className="condition-config">
|
||||||
<div className="form-group compact">
|
{/* 逻辑运算符选择 */}
|
||||||
<label className="form-label">变量A</label>
|
<div className="form-group compact" style={{marginBottom: '8px'}}>
|
||||||
<input
|
<label className="form-label">逻辑关系</label>
|
||||||
type="text"
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.logic || 'AND'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updatedTriggerConfig = {
|
const updatedTriggerConfig = {
|
||||||
...currentEntry.trigger_config,
|
...currentEntry.trigger_config,
|
||||||
@@ -1198,74 +1278,214 @@ const WorldBook = () => {
|
|||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
variable_a: e.target.value
|
logic: e.target.value
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<option value="AND">且 (AND)</option>
|
||||||
|
<option value="OR">或 (OR)</option>
|
||||||
|
<option value="NOT_A">非 A (NOT)</option>
|
||||||
|
<option value="NOT_B">非 B (NOT)</option>
|
||||||
|
<option value="XOR">异或 (XOR)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group compact">
|
{/* 条件 A */}
|
||||||
<label className="form-label">运算符</label>
|
<div className="condition-block">
|
||||||
<select
|
<div className="condition-header">
|
||||||
className="form-input"
|
<span className="condition-label">条件 A</span>
|
||||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
|
</div>
|
||||||
onChange={(e) => {
|
<div className="condition-fields">
|
||||||
const updatedTriggerConfig = {
|
<input
|
||||||
...currentEntry.trigger_config,
|
|
||||||
triggers: {
|
|
||||||
...currentEntry.trigger_config?.triggers,
|
|
||||||
condition: [
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
|
||||||
operator: e.target.value
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="等于">等于</option>
|
|
||||||
<option value="大于">大于</option>
|
|
||||||
<option value="小于">小于</option>
|
|
||||||
<option value="不小于">不小于</option>
|
|
||||||
<option value="不大于">不大于</option>
|
|
||||||
<option value="不等于">不等于</option>
|
|
||||||
<option value="包括">包括</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group compact">
|
|
||||||
<label className="form-label">变量B</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
placeholder="变量/值 A"
|
||||||
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedTriggerConfig = {
|
||||||
|
...currentEntry.trigger_config,
|
||||||
|
triggers: {
|
||||||
|
...currentEntry.trigger_config?.triggers,
|
||||||
|
condition: [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
|
variable_a: e.target.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator_a || '='}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedTriggerConfig = {
|
||||||
|
...currentEntry.trigger_config,
|
||||||
|
triggers: {
|
||||||
|
...currentEntry.trigger_config?.triggers,
|
||||||
|
condition: [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
|
operator_a: e.target.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="=">等于</option>
|
||||||
|
<option value=">">大于</option>
|
||||||
|
<option value="<">小于</option>
|
||||||
|
<option value=">=">不小于</option>
|
||||||
|
<option value="<=">不大于</option>
|
||||||
|
<option value="!=">不等于</option>
|
||||||
|
<option value="contains">包含</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="比较值"
|
||||||
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.value_a || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedTriggerConfig = {
|
||||||
|
...currentEntry.trigger_config,
|
||||||
|
triggers: {
|
||||||
|
...currentEntry.trigger_config?.triggers,
|
||||||
|
condition: [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
|
value_a: e.target.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条件 B */}
|
||||||
|
<div className="condition-block">
|
||||||
|
<div className="condition-header">
|
||||||
|
<span className="condition-label">条件 B</span>
|
||||||
|
</div>
|
||||||
|
<div className="condition-fields">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="变量/值 A"
|
||||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updatedTriggerConfig = {
|
const updatedTriggerConfig = {
|
||||||
...currentEntry.trigger_config,
|
...currentEntry.trigger_config,
|
||||||
triggers: {
|
triggers: {
|
||||||
...currentEntry.trigger_config?.triggers,
|
...currentEntry.trigger_config?.triggers,
|
||||||
condition: [
|
condition: [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
variable_b: e.target.value
|
variable_b: e.target.value
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
<select
|
||||||
</div>
|
className="form-input"
|
||||||
)}
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator_b || '='}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedTriggerConfig = {
|
||||||
|
...currentEntry.trigger_config,
|
||||||
|
triggers: {
|
||||||
|
...currentEntry.trigger_config?.triggers,
|
||||||
|
condition: [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
|
operator_b: e.target.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="=">等于</option>
|
||||||
|
<option value=">">大于</option>
|
||||||
|
<option value="<">小于</option>
|
||||||
|
<option value=">=">不小于</option>
|
||||||
|
<option value="<=">不大于</option>
|
||||||
|
<option value="!=">不等于</option>
|
||||||
|
<option value="contains">包含</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="比较值"
|
||||||
|
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.value_b || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updatedTriggerConfig = {
|
||||||
|
...currentEntry.trigger_config,
|
||||||
|
triggers: {
|
||||||
|
...currentEntry.trigger_config?.triggers,
|
||||||
|
condition: [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||||
|
value_b: e.target.value
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 条件预览 */}
|
||||||
|
<div className="condition-preview">
|
||||||
|
<span className="preview-label">预览:</span>
|
||||||
|
<code className="preview-code">
|
||||||
|
{(() => {
|
||||||
|
const config = currentEntry.trigger_config?.triggers?.condition?.[1] || {};
|
||||||
|
const logic = config.logic || 'AND';
|
||||||
|
const condA = `${config.variable_a || '?'} ${config.operator_a || '='} ${config.value_a || '?'}`;
|
||||||
|
const condB = `${config.variable_b || '?'} ${config.operator_b || '='} ${config.value_b || '?'}`;
|
||||||
|
|
||||||
|
const logicSymbols = {
|
||||||
|
'AND': '∧',
|
||||||
|
'OR': '∨',
|
||||||
|
'NOT_A': '¬',
|
||||||
|
'NOT_B': '¬',
|
||||||
|
'XOR': '⊕'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (logic === 'NOT_A') {
|
||||||
|
return `¬(${condA})`;
|
||||||
|
} else if (logic === 'NOT_B') {
|
||||||
|
return `¬(${condB})`;
|
||||||
|
} else {
|
||||||
|
return `(${condA}) ${logicSymbols[logic]} (${condB})`;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1286,13 +1506,21 @@ const WorldBook = () => {
|
|||||||
<div style={{flex: 1}}/>
|
<div style={{flex: 1}}/>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => setShowEditPanel(false)}
|
onClick={() => {
|
||||||
|
setShowEditPanel(false);
|
||||||
|
setLocalEntry(null); // ✅ 清空本地状态
|
||||||
|
setCurrentEntry(null); // ✅ 清空当前条目
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => setShowEditPanel(false)}
|
onClick={() => {
|
||||||
|
setShowEditPanel(false);
|
||||||
|
setLocalEntry(null); // ✅ 清空本地状态
|
||||||
|
setCurrentEntry(null); // ✅ 清空当前条目
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
保存并关闭
|
保存并关闭
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
229
frontend/src/components/SideBarRight/tabs/Settings/Settings.jsx
Normal file
229
frontend/src/components/SideBarRight/tabs/Settings/Settings.jsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './Settings.css';
|
||||||
|
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||||
|
import { HistoryMode } from '../../../types/internal.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置Tab - 显示和编辑当前聊天的总结配置
|
||||||
|
*/
|
||||||
|
const Settings = () => {
|
||||||
|
const { currentRole, currentChat } = useChatBoxStore();
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 加载设置
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentRole && currentChat) {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
}, [currentRole, currentChat]);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
if (!currentRole || !currentChat) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/summary-status`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('加载设置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Settings] 加载设置失败:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentRole || !currentChat || !settings) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/settings`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('保存设置失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('设置已保存');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Settings] 保存设置失败:', err);
|
||||||
|
alert('保存失败: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSummaryConfig = (field, value) => {
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
summaryConfig: {
|
||||||
|
...prev.summaryConfig,
|
||||||
|
[field]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentRole || !currentChat) {
|
||||||
|
return (
|
||||||
|
<div className="settings-tab">
|
||||||
|
<div className="settings-empty">
|
||||||
|
<p>请先选择一个角色和聊天</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="settings-tab">
|
||||||
|
<div className="settings-loading">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="settings-tab">
|
||||||
|
<div className="settings-error">
|
||||||
|
<p>错误: {error}</p>
|
||||||
|
<button onClick={loadSettings}>重试</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-tab">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h3>聊天设置</h3>
|
||||||
|
<button
|
||||||
|
className="save-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
{/* 历史记录模式 */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">历史记录模式</label>
|
||||||
|
<select
|
||||||
|
className="setting-select"
|
||||||
|
value={settings?.historyMode || 'full'}
|
||||||
|
onChange={(e) => setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
historyMode: e.target.value
|
||||||
|
}))}
|
||||||
|
disabled={settings?.historyMode === 'rag'}
|
||||||
|
>
|
||||||
|
<option value="full">全量模式(保留所有消息)</option>
|
||||||
|
<option value="summary">总结模式(定期总结历史)</option>
|
||||||
|
<option value="rag" disabled>
|
||||||
|
RAG模式(需要API配置,选择后不可取消)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
{settings?.historyMode === 'rag' && (
|
||||||
|
<small className="setting-hint warning">
|
||||||
|
⚠️ RAG模式一旦选择不可取消
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总结配置(仅在summary模式下显示) */}
|
||||||
|
{(settings?.historyMode === 'summary' || settings?.summaryConfig) && (
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">总结配置</label>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>总结间隔(条)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
value={settings?.summaryConfig?.interval || 10}
|
||||||
|
onChange={(e) => updateSummaryConfig('interval', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>保留最近楼层</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={settings?.summaryConfig?.recentFloorsToKeep || 5}
|
||||||
|
onChange={(e) => updateSummaryConfig('recentFloorsToKeep', Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row checkbox-row">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings?.summaryConfig?.includeUserInput !== false}
|
||||||
|
onChange={(e) => updateSummaryConfig('includeUserInput', e.target.checked)}
|
||||||
|
/>
|
||||||
|
总结时包含用户输入
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-row">
|
||||||
|
<label>总结提示词</label>
|
||||||
|
<textarea
|
||||||
|
value={settings?.summaryConfig?.summaryPrompt || '请总结以下对话内容,保留关键信息和上下文。'}
|
||||||
|
onChange={(e) => updateSummaryConfig('summaryPrompt', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small className="setting-hint">
|
||||||
|
💡 总结间隔:AI回复达到此数量时触发总结<br />
|
||||||
|
保留楼层:最近X条不总结
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 计数器状态 */}
|
||||||
|
{settings?.historyMode === 'summary' && (
|
||||||
|
<div className="setting-group status-group">
|
||||||
|
<label className="setting-label">总结状态</label>
|
||||||
|
<div className="status-info">
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">当前计数:</span>
|
||||||
|
<span className="status-value">{settings?.summaryCounter || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="status-item">
|
||||||
|
<span className="status-label">上次总结楼层:</span>
|
||||||
|
<span className="status-value">L{settings?.lastSummaryFloor || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -273,3 +273,104 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 动态表格样式(键值对)==================== */
|
||||||
|
.dynamic-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-key {
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 150px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
padding-left: 12px;
|
||||||
|
border-left: 2px solid var(--color-border-light);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ 可编辑输入框样式 */
|
||||||
|
.table-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-input:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-input:focus {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SillyTavern 标签区域 */
|
||||||
|
.tags-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--color-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-header {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,66 +5,68 @@ import './Table.css';
|
|||||||
const Table = () => {
|
const Table = () => {
|
||||||
// ✅ 从 Zustand store 获取状态和方法
|
// ✅ 从 Zustand store 获取状态和方法
|
||||||
const {
|
const {
|
||||||
tags,
|
tableHeaders,
|
||||||
|
tableDefaults,
|
||||||
isLoading,
|
isLoading,
|
||||||
currentRole,
|
currentRole,
|
||||||
currentChat,
|
currentChat,
|
||||||
loadTags,
|
loadTags,
|
||||||
updateTags,
|
refresh,
|
||||||
addTag,
|
updateTableData // ✅ 新增:保存方法
|
||||||
deleteTag,
|
|
||||||
moveLeft,
|
|
||||||
moveRight,
|
|
||||||
editTag,
|
|
||||||
refresh
|
|
||||||
} = useTableStore();
|
} = useTableStore();
|
||||||
|
|
||||||
// 本地 UI 状态(不需要持久化)
|
// 本地编辑状态
|
||||||
const [editingIndex, setEditingIndex] = useState(null);
|
const [editingValues, setEditingValues] = useState({});
|
||||||
const [editValue, setEditValue] = useState('');
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [newTagInput, setNewTagInput] = useState('');
|
|
||||||
|
|
||||||
// 监听角色/聊天变化,自动加载数据
|
// 监听角色/聊天变化,自动加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRole) {
|
if (currentRole) {
|
||||||
loadTags(currentRole, currentChat);
|
loadTags(currentRole, currentChat);
|
||||||
|
setEditingValues({}); // 重置编辑状态
|
||||||
}
|
}
|
||||||
}, [currentRole, currentChat, loadTags]);
|
}, [currentRole, currentChat, loadTags]);
|
||||||
|
|
||||||
// 开始编辑标签
|
// ✅ 处理值变化
|
||||||
const handleStartEdit = (index, value) => {
|
const handleValueChange = (key, value) => {
|
||||||
setEditingIndex(index);
|
setEditingValues(prev => ({
|
||||||
setEditValue(value);
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存编辑
|
// ✅ 保存单个字段(失焦时自动保存)
|
||||||
const handleSaveEdit = () => {
|
const handleBlur = async (key) => {
|
||||||
if (editingIndex === null) return;
|
const newValue = editingValues[key];
|
||||||
|
if (newValue === undefined || newValue === tableDefaults[key]) {
|
||||||
|
return; // 没有变化,不保存
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
editTag(editingIndex, editValue);
|
// 更新 tableDefaults
|
||||||
setEditingIndex(null);
|
const newTableDefaults = {
|
||||||
setEditValue('');
|
...tableDefaults,
|
||||||
|
[key]: newValue
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateTableData({ tableDefaults: newTableDefaults });
|
||||||
|
|
||||||
|
// 清除编辑状态
|
||||||
|
setEditingValues(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[key];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消编辑
|
// ✅ 按 Enter 键保存
|
||||||
const handleCancelEdit = () => {
|
const handleKeyDown = (e, key) => {
|
||||||
setEditingIndex(null);
|
if (e.key === 'Enter') {
|
||||||
setEditValue('');
|
e.target.blur(); // 触发 blur 事件
|
||||||
};
|
}
|
||||||
|
|
||||||
// 添加新标签(支持批量输入)
|
|
||||||
const handleAddTag = () => {
|
|
||||||
const input = newTagInput.trim();
|
|
||||||
if (!input) return;
|
|
||||||
|
|
||||||
// 智能分割:支持空格、逗号、分号作为分隔符
|
|
||||||
const newTagsList = input
|
|
||||||
.split(/[\s,;,;]+/)
|
|
||||||
.filter(tag => tag.trim() !== '')
|
|
||||||
.map(tag => tag.trim());
|
|
||||||
|
|
||||||
addTag(newTagsList);
|
|
||||||
setNewTagInput('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果没有角色,显示提示
|
// 如果没有角色,显示提示
|
||||||
@@ -82,116 +84,69 @@ const Table = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 如果有动态表格数据,显示键值对表格
|
||||||
|
if (tableHeaders.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="table-panel">
|
||||||
|
<div className="tab-header">
|
||||||
|
<span className="title-text">动态表格</span>
|
||||||
|
<div className="header-actions">
|
||||||
|
<span className="table-info">
|
||||||
|
{tableHeaders.length} 个字段 {isSaving && '💾'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="refresh-btn"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
title="刷新数据"
|
||||||
|
>
|
||||||
|
{isLoading ? '⏳' : '🔄'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
{/* ✅ 动态表格 - 可编辑的键值对展示 */}
|
||||||
|
<div className="dynamic-table">
|
||||||
|
{tableHeaders.map((header, index) => {
|
||||||
|
const currentValue = editingValues[header] !== undefined
|
||||||
|
? editingValues[header]
|
||||||
|
: (tableDefaults[header] !== undefined ? tableDefaults[header] : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="table-row">
|
||||||
|
<div className="table-key">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
<div className="table-value">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="table-input"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleValueChange(header, e.target.value)}
|
||||||
|
onBlur={() => handleBlur(header)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, header)}
|
||||||
|
placeholder="输入值..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 否则显示空状态
|
||||||
return (
|
return (
|
||||||
<div className="table-panel">
|
<div className="table-panel">
|
||||||
<div className="tab-header">
|
<div className="tab-header">
|
||||||
<span className="title-text">动态表格</span>
|
<span className="title-text">动态表格</span>
|
||||||
<div className="header-actions">
|
|
||||||
<span className="table-info">
|
|
||||||
{tags.length} 个标签
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="refresh-btn"
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
title="刷新数据"
|
|
||||||
>
|
|
||||||
{isLoading ? '⏳' : '🔄'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="empty-table">
|
||||||
<div className="table-container">
|
<p>📊</p>
|
||||||
{/* 标签列表 */}
|
<p>该角色没有动态表格数据</p>
|
||||||
<div className="tags-container">
|
|
||||||
{tags.length === 0 ? (
|
|
||||||
<div className="no-tags-hint">
|
|
||||||
暂无标签,请在下方添加
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
tags.map((tag, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`tag-item ${editingIndex === index ? 'editing' : ''}`}
|
|
||||||
>
|
|
||||||
{editingIndex === index ? (
|
|
||||||
// 编辑模式
|
|
||||||
<div className="tag-edit-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="tag-edit-input"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleSaveEdit();
|
|
||||||
if (e.key === 'Escape') handleCancelEdit();
|
|
||||||
}}
|
|
||||||
onBlur={handleSaveEdit}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 显示模式
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="tag-text"
|
|
||||||
onDoubleClick={() => handleStartEdit(index, tag)}
|
|
||||||
title="双击编辑"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
<div className="tag-actions">
|
|
||||||
<button
|
|
||||||
className="tag-action-btn move-left"
|
|
||||||
onClick={() => moveLeft(index)}
|
|
||||||
disabled={index === 0}
|
|
||||||
title="左移"
|
|
||||||
>
|
|
||||||
◀
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="tag-action-btn move-right"
|
|
||||||
onClick={() => moveRight(index)}
|
|
||||||
disabled={index === tags.length - 1}
|
|
||||||
title="右移"
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="tag-action-btn delete"
|
|
||||||
onClick={() => deleteTag(index)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 添加新标签输入框 */}
|
|
||||||
<div className="add-tag-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="add-tag-input"
|
|
||||||
placeholder="输入标签(支持空格/逗号分隔多个)..."
|
|
||||||
value={newTagInput}
|
|
||||||
onChange={(e) => setNewTagInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleAddTag();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="add-tag-btn"
|
|
||||||
onClick={handleAddTag}
|
|
||||||
disabled={!newTagInput.trim()}
|
|
||||||
>
|
|
||||||
+ 添加
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
border-bottom: 1px solid var(--color-border-light);
|
border-bottom: 1px solid var(--color-border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
z-index: 100; /* 降低层级,不遮挡悬浮提示 */
|
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar */
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ const Toolbar = () => {
|
|||||||
<div className="panel-overlay" ref={panelRef}>
|
<div className="panel-overlay" ref={panelRef}>
|
||||||
<div className="panel-content settings-panel">
|
<div className="panel-content settings-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h3>系统设置</h3>
|
<h2>系统设置</h2>
|
||||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Import global styles */
|
/* Import global styles */
|
||||||
@import './styles/variables.css';
|
@import './styles/variables.css';
|
||||||
@import './styles/reset.css';
|
@import './styles/reset.css';
|
||||||
|
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
|
||||||
|
|
||||||
/* ==================== Main Layout ==================== */
|
/* ==================== Main Layout ==================== */
|
||||||
.app {
|
.app {
|
||||||
@@ -128,12 +129,12 @@
|
|||||||
radial-gradient(circle at 80% 70%, rgba(109, 140, 255, 0.03) 0%, transparent 50%),
|
radial-gradient(circle at 80% 70%, rgba(109, 140, 255, 0.03) 0%, transparent 50%),
|
||||||
linear-gradient(180deg, var(--color-bg-primary) 0%, var(--color-bg-subtle) 100%);
|
linear-gradient(180deg, var(--color-bg-primary) 0%, var(--color-bg-subtle) 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: var(--z-background); /* ✅ 基础层 - 背景 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-area > * {
|
.chat-area > * {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: var(--z-base-content); /* ✅ 基础层 - 内容 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-right {
|
.sidebar-right {
|
||||||
|
|||||||
39
frontend/src/styles/z-index.css
Normal file
39
frontend/src/styles/z-index.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Z-Index CSS 变量定义
|
||||||
|
* ====================
|
||||||
|
*
|
||||||
|
* 在 CSS 文件中使用方式:
|
||||||
|
* z-index: var(--z-dropdown-menu);
|
||||||
|
* z-index: var(--z-modal-overlay);
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ==================== 基础层 (0-99) ==================== */
|
||||||
|
--z-background: 0;
|
||||||
|
--z-base-content: 1;
|
||||||
|
--z-divider: 10;
|
||||||
|
|
||||||
|
/* ==================== 组件层 (100-999) ==================== */
|
||||||
|
--z-top-bar: 100;
|
||||||
|
--z-sidebar: 100;
|
||||||
|
--z-dropdown-menu: 1000;
|
||||||
|
--z-sort-panel: 1100;
|
||||||
|
--z-tooltip: 1200;
|
||||||
|
--z-chat-actions: 1000;
|
||||||
|
--z-character-preview: 1000;
|
||||||
|
|
||||||
|
/* ==================== 弹窗层 (10000-19999) ==================== */
|
||||||
|
--z-modal-overlay: 10000;
|
||||||
|
--z-modal-backdrop: 10000; /* TopBar 面板遮罩 */
|
||||||
|
--z-modal-content: 10100;
|
||||||
|
--z-edit-panel-overlay: 10200;
|
||||||
|
--z-edit-panel-content: 10300;
|
||||||
|
|
||||||
|
/* ==================== 通知层 (20000-29999) ==================== */
|
||||||
|
--z-toast-container: 20000;
|
||||||
|
--z-toast-item: 20100;
|
||||||
|
|
||||||
|
/* ==================== 系统层 (30000+) ==================== */
|
||||||
|
--z-loading-spinner: 30000;
|
||||||
|
--z-error-boundary: 30100;
|
||||||
|
}
|
||||||
109
frontend/src/styles/z-index.ts
Normal file
109
frontend/src/styles/z-index.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Z-Index 层级规范
|
||||||
|
* ==================
|
||||||
|
*
|
||||||
|
* 本文件定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致。
|
||||||
|
*
|
||||||
|
* 层级划分原则:
|
||||||
|
* - 每层之间预留足够的空间(至少 100),方便后续插入新层级
|
||||||
|
* - 同层级的元素使用相近的 z-index 值
|
||||||
|
* - 避免使用过大的数值(如 99999),保持可读性
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const Z_INDEX = {
|
||||||
|
// ==================== 基础层 (0-99) ====================
|
||||||
|
// 用于页面背景、基础布局等
|
||||||
|
|
||||||
|
/** 最底层 - 背景装饰 */
|
||||||
|
BACKGROUND: 0,
|
||||||
|
|
||||||
|
/** 基础内容层 - 普通文本、图片等 */
|
||||||
|
BASE_CONTENT: 1,
|
||||||
|
|
||||||
|
/** 分割线、边框装饰 */
|
||||||
|
DIVIDER: 10,
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 组件层 (100-999) ====================
|
||||||
|
// 用于常规 UI 组件,如下拉菜单、悬浮提示等
|
||||||
|
|
||||||
|
/** TopBar 导航栏 */
|
||||||
|
TOP_BAR: 100,
|
||||||
|
|
||||||
|
/** 侧边栏容器 */
|
||||||
|
SIDEBAR: 100,
|
||||||
|
|
||||||
|
/** 下拉菜单 - 预设操作菜单、世界书选择菜单等 */
|
||||||
|
DROPDOWN_MENU: 1000,
|
||||||
|
|
||||||
|
/** 排序设置面板 */
|
||||||
|
SORT_PANEL: 1100,
|
||||||
|
|
||||||
|
/** 悬浮提示 Tooltip */
|
||||||
|
TOOLTIP: 1200,
|
||||||
|
|
||||||
|
/** 聊天消息操作按钮 */
|
||||||
|
CHAT_ACTIONS: 1000,
|
||||||
|
|
||||||
|
/** 角色卡预览弹窗 */
|
||||||
|
CHARACTER_PREVIEW: 1000,
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 弹窗层 (10000-19999) ====================
|
||||||
|
// 用于模态对话框、编辑面板等需要覆盖整个页面的元素
|
||||||
|
|
||||||
|
/** 对话框遮罩层背景 */
|
||||||
|
MODAL_OVERLAY: 10000,
|
||||||
|
|
||||||
|
/** TopBar 面板遮罩 */
|
||||||
|
MODAL_BACKDROP: 10000,
|
||||||
|
|
||||||
|
/** 对话框内容 - API 配置对话框、预设保存对话框等 */
|
||||||
|
MODAL_CONTENT: 10100,
|
||||||
|
|
||||||
|
/** 世界书编辑面板遮罩层 */
|
||||||
|
EDIT_PANEL_OVERLAY: 10200,
|
||||||
|
|
||||||
|
/** 世界书编辑面板内容 */
|
||||||
|
EDIT_PANEL_CONTENT: 10300,
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 通知层 (20000-29999) ====================
|
||||||
|
// 用于全局通知、Toast 提示等
|
||||||
|
|
||||||
|
/** Toast 通知容器 */
|
||||||
|
TOAST_CONTAINER: 20000,
|
||||||
|
|
||||||
|
/** Toast 通知项 */
|
||||||
|
TOAST_ITEM: 20100,
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 系统层 (30000+) ====================
|
||||||
|
// 用于系统级元素,如加载动画、错误边界等
|
||||||
|
|
||||||
|
/** 全局加载动画 */
|
||||||
|
LOADING_SPINNER: 30000,
|
||||||
|
|
||||||
|
/** 错误边界覆盖层 */
|
||||||
|
ERROR_BOUNDARY: 30100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* import { Z_INDEX } from '../styles/z-index';
|
||||||
|
*
|
||||||
|
* .dropdown-menu {
|
||||||
|
* z-index: ${Z_INDEX.DROPDOWN_MENU};
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* .modal-overlay {
|
||||||
|
* z-index: ${Z_INDEX.MODAL_OVERLAY};
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* .edit-panel {
|
||||||
|
* z-index: ${Z_INDEX.EDIT_PANEL_CONTENT};
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default Z_INDEX;
|
||||||
159
frontend/src/utils/summaryCounter.js
Normal file
159
frontend/src/utils/summaryCounter.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 聊天总结计数器管理器
|
||||||
|
*
|
||||||
|
* 负责在LocalStorage中维护每个聊天的总结计数器
|
||||||
|
* 避免频繁请求后端,提升性能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'chat_summary_counters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计数器数据结构
|
||||||
|
* @typedef {Object} CounterData
|
||||||
|
* @property {number} counter - 当前计数器值
|
||||||
|
* @property {number} lastSummaryFloor - 最后一次总结的楼层
|
||||||
|
* @property {number} updatedAt - 最后更新时间戳
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有聊天的计数器数据
|
||||||
|
* @returns {Object.<string, CounterData>}
|
||||||
|
*/
|
||||||
|
export function getAllCounters() {
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return data ? JSON.parse(data) : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SummaryCounter] 读取计数器失败:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定聊天的计数器
|
||||||
|
* @param {string} chatKey - 聊天键(格式:role_name/chat_name)
|
||||||
|
* @returns {CounterData}
|
||||||
|
*/
|
||||||
|
export function getCounter(chatKey) {
|
||||||
|
const allCounters = getAllCounters();
|
||||||
|
return allCounters[chatKey] || {
|
||||||
|
counter: 0,
|
||||||
|
lastSummaryFloor: 0,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新指定聊天的计数器
|
||||||
|
* @param {string} chatKey - 聊天键
|
||||||
|
* @param {Partial<CounterData>} updates - 更新的数据
|
||||||
|
*/
|
||||||
|
export function updateCounter(chatKey, updates) {
|
||||||
|
try {
|
||||||
|
const allCounters = getAllCounters();
|
||||||
|
const current = allCounters[chatKey] || {
|
||||||
|
counter: 0,
|
||||||
|
lastSummaryFloor: 0,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
allCounters[chatKey] = {
|
||||||
|
...current,
|
||||||
|
...updates,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(allCounters));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SummaryCounter] 更新计数器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加计数器(当AI回复被用户确认时调用)
|
||||||
|
* @param {string} chatKey - 聊天键
|
||||||
|
* @param {number} increment - 增加的值(默认1)
|
||||||
|
* @returns {number} 新的计数器值
|
||||||
|
*/
|
||||||
|
export function incrementCounter(chatKey, increment = 1) {
|
||||||
|
const current = getCounter(chatKey);
|
||||||
|
const newCounter = current.counter + increment;
|
||||||
|
|
||||||
|
updateCounter(chatKey, { counter: newCounter });
|
||||||
|
|
||||||
|
console.log(`[SummaryCounter] ${chatKey} 计数器: ${current.counter} -> ${newCounter}`);
|
||||||
|
|
||||||
|
return newCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置计数器(总结完成后调用)
|
||||||
|
* @param {string} chatKey - 聊天键
|
||||||
|
* @param {number} lastSummaryFloor - 最后一次总结的楼层
|
||||||
|
*/
|
||||||
|
export function resetCounter(chatKey, lastSummaryFloor) {
|
||||||
|
updateCounter(chatKey, {
|
||||||
|
counter: 0,
|
||||||
|
lastSummaryFloor
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SummaryCounter] ${chatKey} 计数器已重置,lastSummaryFloor=${lastSummaryFloor}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否应该触发总结
|
||||||
|
* @param {string} chatKey - 聊天键
|
||||||
|
* @param {number} interval - 总结间隔阈值
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldTriggerSummary(chatKey, interval) {
|
||||||
|
const current = getCounter(chatKey);
|
||||||
|
return current.counter >= interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定聊天的计数器
|
||||||
|
* @param {string} chatKey - 聊天键
|
||||||
|
*/
|
||||||
|
export function clearCounter(chatKey) {
|
||||||
|
try {
|
||||||
|
const allCounters = getAllCounters();
|
||||||
|
delete allCounters[chatKey];
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(allCounters));
|
||||||
|
console.log(`[SummaryCounter] 已清除 ${chatKey} 的计数器`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SummaryCounter] 清除计数器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有计数器
|
||||||
|
*/
|
||||||
|
export function clearAllCounters() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
console.log('[SummaryCounter] 已清除所有计数器');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SummaryCounter] 清除所有计数器失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取存储占用大小(字节)
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getStorageSize() {
|
||||||
|
const data = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return data ? new Blob([data]).size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化存储大小
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getFormattedStorageSize() {
|
||||||
|
const bytes = getStorageSize();
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
99
test_character_update.py
Normal file
99
test_character_update.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
测试角色卡更新API
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
BASE_URL = "http://localhost:8000/api"
|
||||||
|
|
||||||
|
def test_update_character():
|
||||||
|
"""测试更新角色卡"""
|
||||||
|
|
||||||
|
# 1. 先获取所有角色
|
||||||
|
print("1. 获取角色列表...")
|
||||||
|
response = requests.get(f"{BASE_URL}/characters/")
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"❌ 获取角色列表失败: {response.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
characters = response.json()
|
||||||
|
if not characters:
|
||||||
|
print("❌ 没有角色卡可供测试")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 选择第一个角色
|
||||||
|
test_char = characters[0]
|
||||||
|
char_name = test_char['name']
|
||||||
|
print(f"✅ 找到角色: {char_name}")
|
||||||
|
|
||||||
|
# 2. 获取角色详情
|
||||||
|
print(f"\n2. 获取角色 '{char_name}' 的详情...")
|
||||||
|
response = requests.get(f"{BASE_URL}/characters/{char_name}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"❌ 获取角色详情失败: {response.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
original_data = response.json()
|
||||||
|
print(f"✅ 原始描述长度: {len(original_data.get('description', ''))}")
|
||||||
|
|
||||||
|
# 3. 更新角色(添加测试标记)
|
||||||
|
print(f"\n3. 更新角色 '{char_name}'...")
|
||||||
|
update_data = {
|
||||||
|
"description": original_data.get('description', '') + '\n\n[测试更新标记]',
|
||||||
|
"tags": original_data.get('tags', []) + ['test_update']
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(
|
||||||
|
f"{BASE_URL}/characters/{char_name}",
|
||||||
|
json=update_data,
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"❌ 更新失败: {response.status_code}")
|
||||||
|
print(f"响应内容: {response.text}")
|
||||||
|
return
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
print(f"✅ 更新成功: {result.get('success')}")
|
||||||
|
|
||||||
|
# 4. 验证更新
|
||||||
|
print(f"\n4. 验证更新结果...")
|
||||||
|
response = requests.get(f"{BASE_URL}/characters/{char_name}")
|
||||||
|
updated_data = response.json()
|
||||||
|
|
||||||
|
new_desc = updated_data.get('description', '')
|
||||||
|
if '[测试更新标记]' in new_desc:
|
||||||
|
print("✅ 验证成功:描述已更新")
|
||||||
|
else:
|
||||||
|
print("❌ 验证失败:描述未更新")
|
||||||
|
|
||||||
|
if 'test_update' in updated_data.get('tags', []):
|
||||||
|
print("✅ 验证成功:标签已更新")
|
||||||
|
else:
|
||||||
|
print("❌ 验证失败:标签未更新")
|
||||||
|
|
||||||
|
# 5. 恢复原始数据
|
||||||
|
print(f"\n5. 恢复原始数据...")
|
||||||
|
response = requests.put(
|
||||||
|
f"{BASE_URL}/characters/{char_name}",
|
||||||
|
json={
|
||||||
|
"description": original_data.get('description', ''),
|
||||||
|
"tags": original_data.get('tags', [])
|
||||||
|
},
|
||||||
|
headers={'Content-Type': 'application/json'}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✅ 数据已恢复")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ 恢复失败: {response.status_code}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
test_update_character()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 测试出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
76
test_chat_summary.py
Normal file
76
test_chat_summary.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
简化版聊天记录总结功能测试
|
||||||
|
直接操作现有数据进行测试
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 添加backend目录到Python路径
|
||||||
|
backend_root = Path(__file__).parent / "backend"
|
||||||
|
sys.path.insert(0, str(backend_root))
|
||||||
|
|
||||||
|
from services.chat_service import chat_service
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_summary():
|
||||||
|
"""简单测试:直接操作现有聊天数据"""
|
||||||
|
print("=" * 60)
|
||||||
|
print("聊天记录总结功能简单测试")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# 列出所有现有聊天
|
||||||
|
print("\n现有聊天列表:")
|
||||||
|
all_chats = chat_service.list_all_chats()
|
||||||
|
|
||||||
|
if not all_chats:
|
||||||
|
print(" 没有找到任何聊天,请先创建角色和聊天")
|
||||||
|
return
|
||||||
|
|
||||||
|
for role_name, chats in all_chats.items():
|
||||||
|
print(f"\n 角色: {role_name}")
|
||||||
|
for chat in chats[:3]: # 只显示前3个
|
||||||
|
print(f" - {chat['chat_name']} ({chat['message_count']}条消息)")
|
||||||
|
|
||||||
|
# 选择第一个聊天进行测试
|
||||||
|
first_role = list(all_chats.keys())[0]
|
||||||
|
first_chat = all_chats[first_role][0]['chat_name']
|
||||||
|
|
||||||
|
print(f"\n选择测试: {first_role}/{first_chat}")
|
||||||
|
|
||||||
|
# 获取聊天记录
|
||||||
|
chat_data = chat_service.get_chat(first_role, first_chat)
|
||||||
|
messages = chat_data['messages']
|
||||||
|
|
||||||
|
print(f"总消息数: {len(messages)}")
|
||||||
|
|
||||||
|
if len(messages) < 5:
|
||||||
|
print("消息数量不足,无法测试总结功能")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 显示前5条消息
|
||||||
|
print("\n前5条消息:")
|
||||||
|
for i, msg in enumerate(messages[:5], 1):
|
||||||
|
name = msg.get('name', 'Unknown')
|
||||||
|
mes = msg.get('mes', '')[:50]
|
||||||
|
print(f" L{i}: {name}: {mes}...")
|
||||||
|
|
||||||
|
# 测试总结功能(模拟)
|
||||||
|
print("\n模拟总结 L1-L3:")
|
||||||
|
print(" 原始消息:")
|
||||||
|
for i in range(3):
|
||||||
|
msg = messages[i]
|
||||||
|
print(f" L{i+1}: {msg.get('name')}: {msg.get('mes', '')[:30]}...")
|
||||||
|
|
||||||
|
print("\n 总结后效果:")
|
||||||
|
print(" L1: [空] (is_summarized=true)")
|
||||||
|
print(" L2: [空] (is_summarized=true)")
|
||||||
|
print(" L3: [总结文本] (is_summary=true, summary_range='L1-L3')")
|
||||||
|
print(" L4+: 保持不变")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ 测试完成!请在UI中验证实际效果")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_simple_summary()
|
||||||
Reference in New Issue
Block a user