添加测试、添加总结、全量、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 .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 core.config import settings
|
||||
from pathlib import Path
|
||||
@@ -17,6 +17,7 @@ router.include_router(charactersRoute.router)
|
||||
router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
router.include_router(chatSummaryRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
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调用前)
|
||||
if active_entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
# 将 Pydantic 模型转换为字典
|
||||
entries_dict = [entry.model_dump() for entry in active_entries]
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
"entries": entries_dict
|
||||
})
|
||||
|
||||
# ✅ TODO: RAG检索(暂时为空,待实现)
|
||||
@@ -310,7 +312,7 @@ async def _handle_stream_chat(
|
||||
})
|
||||
|
||||
# ✅ 第3步:调用LLM流式生成
|
||||
chunk_count = 0
|
||||
chunk_count = [0] # 使用列表以便在闭包中修改
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
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))
|
||||
|
||||
@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}")
|
||||
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}")
|
||||
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)
|
||||
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):
|
||||
"""从预设中删除指定组件"""
|
||||
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 如何更新表格数据")
|
||||
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="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||
@@ -158,11 +162,41 @@ class CharacterCard(BaseModel):
|
||||
|
||||
# ==================== 聊天记录 (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):
|
||||
"""
|
||||
项目内部聊天记录头
|
||||
|
||||
包含聊天的元数据,如参与角色、创建时间等。
|
||||
包含聊天的元数据,如参与角色、创建时间等。
|
||||
"""
|
||||
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||
@@ -170,6 +204,12 @@ class ChatHeader(BaseModel):
|
||||
userName: str = Field("User", description="用户角色名")
|
||||
characterName: str = Field(..., description="AI 角色名称")
|
||||
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="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
messageCount: int = Field(0, description="消息数量")
|
||||
@@ -180,7 +220,7 @@ class ChatMessage(BaseModel):
|
||||
"""
|
||||
项目内部聊天消息
|
||||
|
||||
单条对话消息,支持多版本 (swipes)、token 统计等功能。
|
||||
单条对话消息,支持多版本 (swipes)、token 统计、历史记录总结等功能。
|
||||
"""
|
||||
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="发送者名称")
|
||||
@@ -193,6 +233,11 @@ class ChatMessage(BaseModel):
|
||||
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
||||
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||
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):
|
||||
|
||||
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>=0.1.0
|
||||
langchain-core>=0.1.0
|
||||
langchain-openai>=0.0.5
|
||||
langchain-anthropic>=0.1.1
|
||||
openai>=1.12.0
|
||||
|
||||
@@ -100,6 +100,10 @@ class CharacterService:
|
||||
outputSchema=data.get('outputSchema'),
|
||||
avatarPath=avatar_path,
|
||||
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())),
|
||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||
lastChatAt=last_chat_at,
|
||||
|
||||
@@ -10,6 +10,8 @@ import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -453,3 +455,79 @@ class ChatService:
|
||||
except Exception as e:
|
||||
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||
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}")
|
||||
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(
|
||||
self,
|
||||
request_data: Dict[str, Any]
|
||||
@@ -315,15 +294,25 @@ class ChatWorkflowService:
|
||||
"""
|
||||
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 必须是字符串
|
||||
if 'uid' in normalized and not isinstance(normalized['uid'], str):
|
||||
normalized['uid'] = str(normalized['uid'])
|
||||
|
||||
# position 必须是字符串或 None
|
||||
# position 必须是整数或 None
|
||||
if 'position' in normalized:
|
||||
pos = normalized['position']
|
||||
if pos is not None and not isinstance(pos, str):
|
||||
normalized['position'] = str(pos)
|
||||
if pos is not None:
|
||||
try:
|
||||
normalized['position'] = int(pos)
|
||||
except (ValueError, TypeError):
|
||||
normalized['position'] = 0
|
||||
|
||||
# group 必须是列表或 None
|
||||
if 'group' in normalized:
|
||||
@@ -340,6 +329,68 @@ class ChatWorkflowService:
|
||||
|
||||
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(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
@@ -359,7 +410,7 @@ class ChatWorkflowService:
|
||||
character: 角色卡对象
|
||||
|
||||
Returns:
|
||||
List[WorldInfoEntry]: 激活的条目列表
|
||||
List[WorldInfoEntry]: 激活的条目列表(已按位置和顺序排序)
|
||||
"""
|
||||
try:
|
||||
from backend.models.internal import WorldInfoEntry
|
||||
@@ -376,16 +427,31 @@ class ChatWorkflowService:
|
||||
|
||||
world_book_data = request_data.get("worldBookData", {})
|
||||
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
|
||||
|
||||
# === 1. 加载全局世界书 ===
|
||||
global_books = world_book_data.get("globalBooks", [])
|
||||
for wb in global_books:
|
||||
try:
|
||||
wb_name = wb.get("name")
|
||||
if wb_name:
|
||||
wb_data = self.worldbook_service._load_worldbook(wb_name)
|
||||
if wb_data and "entries" in wb_data:
|
||||
print(f"[WorldBook] 📖 加载全局世界书 '{wb_name}',共 {len(wb_data['entries'])} 个条目")
|
||||
for entry_data in wb_data["entries"]:
|
||||
# 检查是否禁用
|
||||
if entry_data.get("disable", False):
|
||||
@@ -399,18 +465,21 @@ class ChatWorkflowService:
|
||||
try:
|
||||
entry = WorldInfoEntry(**normalized_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:
|
||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||
except Exception as e:
|
||||
print(f"[WorldBook] 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
||||
print(f"[WorldBook] ❌ 加载全局世界书失败: {wb.get('name')}, 错误: {e}")
|
||||
|
||||
# === 2. 加载角色绑定的世界书 ===
|
||||
character_book_id = character.worldInfoId
|
||||
if character_book_id:
|
||||
print(f"[WorldBook] 📖 开始加载角色绑定世界书: {character_book_id}")
|
||||
try:
|
||||
wb_data = self.worldbook_service._load_worldbook(character_book_id)
|
||||
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"]:
|
||||
if entry_data.get("disable", False):
|
||||
continue
|
||||
@@ -422,16 +491,42 @@ class ChatWorkflowService:
|
||||
try:
|
||||
entry = WorldInfoEntry(**normalized_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:
|
||||
print(f"[WorldBook] ⚠️ 条目验证失败: {entry_data.get('uid')}, 错误: {validation_error}")
|
||||
else:
|
||||
print(f"[WorldBook] ⚠️ 世界书 '{character_book_id}' 不存在或无条目")
|
||||
except Exception as e:
|
||||
print(f"[WorldBook] 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
||||
print(f"[WorldBook] ❌ 加载角色世界书失败: {character_book_id}, 错误: {e}")
|
||||
else:
|
||||
print(f"[WorldBook] ℹ️ 角色未绑定世界书")
|
||||
|
||||
# === 3. 按位置和顺序排序 ===
|
||||
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
|
||||
|
||||
def _check_entry_activation(
|
||||
@@ -456,23 +551,28 @@ class ChatWorkflowService:
|
||||
trigger_config = entry_data.get("trigger_config", {})
|
||||
triggers = trigger_config.get("triggers", {})
|
||||
|
||||
# 1. 常驻触发 (constant)
|
||||
# 1. 常驻触发 (constant) - 总是激活
|
||||
constant_trigger = triggers.get("constant", [False, None])
|
||||
if constant_trigger[0]: # [true, null]
|
||||
print(f"[WorldBook-Check] 📌 常驻触发 | UID: {entry_data.get('uid')}")
|
||||
return True
|
||||
|
||||
# 2. 关键词触发 (keyword)
|
||||
# 2. 关键词触发 (keyword) - 检查用户输入和聊天历史
|
||||
keyword_trigger = triggers.get("keyword", [False, None])
|
||||
if keyword_trigger[0] and keyword_trigger[1]:
|
||||
keywords = keyword_trigger[1].get("keys", [])
|
||||
if keywords:
|
||||
keyword_config = keyword_trigger[1]
|
||||
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
|
||||
|
||||
# 检查聊天历史
|
||||
for history_text in chat_history:
|
||||
if self._check_keyword_trigger(keywords, history_text):
|
||||
for i, history_text in enumerate(chat_history):
|
||||
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
|
||||
|
||||
# 3. 条件触发 (condition) - 支持动态表格语法
|
||||
@@ -481,19 +581,68 @@ class ChatWorkflowService:
|
||||
conditions = condition_trigger[1].get("conditions", [])
|
||||
if conditions:
|
||||
# 所有条件都必须满足 (AND逻辑)
|
||||
all_conditions_met = True
|
||||
for condition in conditions:
|
||||
cond_text = condition.get("text", "")
|
||||
if cond_text:
|
||||
# 解析条件,如 (tb.力量 > 10)
|
||||
if not self._parse_table_condition(cond_text, table_data):
|
||||
return False
|
||||
return True
|
||||
all_conditions_met = False
|
||||
break
|
||||
|
||||
if all_conditions_met:
|
||||
print(f"[WorldBook-Check] ⚙️ 条件触发 | UID: {entry_data.get('uid')} | 条件数: {len(conditions)}")
|
||||
return True
|
||||
|
||||
# 4. RAG触发 (暂不实现)
|
||||
# rag_trigger = triggers.get("rag", [False, None])
|
||||
|
||||
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(
|
||||
self,
|
||||
role_name: str,
|
||||
@@ -851,7 +1000,7 @@ class ChatWorkflowService:
|
||||
)
|
||||
print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目")
|
||||
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步:加载聊天历史 ===
|
||||
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()...")
|
||||
|
||||
try:
|
||||
async for chunk in self.llm_client.stream_chat(
|
||||
async for chunk_dict in self.llm_client.stream_chat(
|
||||
messages=prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
@@ -909,7 +1058,22 @@ class ChatWorkflowService:
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
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 到达时记录
|
||||
@@ -922,8 +1086,8 @@ class ChatWorkflowService:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s")
|
||||
|
||||
# 调用回调函数发送 chunk
|
||||
await on_chunk(chunk)
|
||||
# ✅ 调用回调函数发送纯文本 chunk
|
||||
await on_chunk(chunk_content)
|
||||
|
||||
except Exception as stream_error:
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
@@ -201,3 +201,42 @@ class PresetService:
|
||||
|
||||
path.unlink()
|
||||
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
|
||||
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||
parts.append(entry.content)
|
||||
parts.append(str(entry.content) if entry.content else "")
|
||||
|
||||
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||
parts.append(f"[Author's note at depth {depth}]")
|
||||
|
||||
# Pos 5: 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)
|
||||
|
||||
@@ -147,10 +147,15 @@ class PromptAssembler:
|
||||
"""
|
||||
在聊天历史的指定深度插入条目 (Pos 6)
|
||||
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||
|
||||
✅ 过滤已被总结的消息(is_summarized=True 且 mes="")
|
||||
"""
|
||||
# 先将历史转换为中间格式
|
||||
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
|
||||
msg_list = []
|
||||
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})
|
||||
|
||||
# 按 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": " ",
|
||||
"seed": -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 配置页面创建');
|
||||
}
|
||||
} else {
|
||||
console.log('[App] ✅ API 配置已从缓存恢复');
|
||||
// 从缓存恢复
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] ❌ API 配置加载失败:', err);
|
||||
|
||||
@@ -534,6 +534,7 @@ const useChatBoxStore = create(
|
||||
if (characterData.first_mes && characterData.first_mes.trim()) {
|
||||
// 创建临时的开场白消息(不保存到后端)
|
||||
messages = [{
|
||||
id: Date.now(), // ✅ 添加唯一 ID
|
||||
floor: 1,
|
||||
mes: characterData.first_mes,
|
||||
is_user: false,
|
||||
|
||||
@@ -72,7 +72,10 @@ const useCharacterCardUIStore = create((set) => ({
|
||||
mes_example: character.mes_example || '',
|
||||
categories: character.categories || [],
|
||||
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 presetData = await response.json();
|
||||
|
||||
// 记录原始数据用于调试
|
||||
console.log('从后端获取的预设数据:', presetData);
|
||||
|
||||
// 提取参数并更新状态,支持内部结构和SillyTavern结构
|
||||
const parameters = {
|
||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||
@@ -158,9 +155,6 @@ const usePresetStore = create(
|
||||
n: presetData.n !== undefined ? presetData.n : 1
|
||||
};
|
||||
|
||||
// 记录映射后的参数用于调试
|
||||
console.log('映射后的参数:', parameters);
|
||||
|
||||
// 处理预设组件 - 支持内部结构和SillyTavern结构
|
||||
let components = [];
|
||||
|
||||
@@ -379,6 +373,41 @@ const usePresetStore = create(
|
||||
newComponents.splice(toIndex, 0, movedComponent);
|
||||
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
|
||||
getPromptOrder: () => {
|
||||
@@ -428,27 +457,23 @@ const usePresetStore = create(
|
||||
console.error('[PresetStore] 恢复状态失败:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state?.selectedPreset) {
|
||||
console.log(`[PresetStore] 🔄 恢复上次选中的预设: ${state.selectedPreset}`);
|
||||
// 异步加载预设详情
|
||||
setTimeout(() => {
|
||||
usePresetStore.getState().fetchPresets().then(() => {
|
||||
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
|
||||
if (state.selectedPreset) {
|
||||
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
|
||||
console.error('[PresetStore] 加载预设详情失败:', err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[PresetStore] 加载预设列表失败:', err);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
// 异步加载预设详情
|
||||
setTimeout(() => {
|
||||
usePresetStore.getState().fetchPresets().then(() => {
|
||||
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
|
||||
if (state.selectedPreset) {
|
||||
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
|
||||
console.error('[PresetStore] 加载预设详情失败:', err);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('[PresetStore] 加载预设列表失败:', err);
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
}
|
||||
}
|
||||
) // ✅ 闭合 persist(
|
||||
) // ✅ 闭合 persist(
|
||||
);
|
||||
|
||||
export default usePresetStore;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import useChatBoxStore from '../Mid/ChatBoxSlice'; // ✅ 导入 ChatBoxStore (default export)
|
||||
|
||||
/**
|
||||
* 动态表格 Store
|
||||
* 管理 SillyTavern 风格的标签数据
|
||||
* 管理角色卡的动态表格数据(键值对结构)
|
||||
*/
|
||||
const useTableStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// ==================== 状态 ====================
|
||||
|
||||
// 标签数组
|
||||
tags: [],
|
||||
// ✅ 动态表格数据(键值对结构)
|
||||
tableHeaders: [], // 表头数组
|
||||
tableDefaults: {}, // 默认值对象
|
||||
|
||||
// 当前角色和聊天
|
||||
currentRole: null,
|
||||
@@ -26,207 +28,92 @@ const useTableStore = create(
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 加载标签数据
|
||||
* 加载动态表格数据
|
||||
* @param {string} role - 角色名
|
||||
* @param {string} chat - 聊天名
|
||||
*/
|
||||
loadTags: async (role, chat) => {
|
||||
if (!role) {
|
||||
set({ tags: [], currentRole: null, currentChat: null });
|
||||
set({
|
||||
tableHeaders: [],
|
||||
tableDefaults: {},
|
||||
currentRole: null,
|
||||
currentChat: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, currentRole: role, currentChat: chat });
|
||||
|
||||
try {
|
||||
let fetchedTags = [];
|
||||
let tableHeaders = [];
|
||||
let tableDefaults = {};
|
||||
|
||||
// 优先级1:从聊天文件读取
|
||||
// ✅ 优先级1:从聊天文件读取(如果聊天中有动态表格数据)
|
||||
if (chat) {
|
||||
console.log('[TableStore] 从聊天文件读取标签数据');
|
||||
console.log('[TableStore] 尝试从聊天文件读取动态表格数据');
|
||||
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
|
||||
|
||||
if (!response.ok) throw new Error('获取聊天数据失败');
|
||||
|
||||
const chatData = await response.json();
|
||||
const header = chatData.header || {};
|
||||
|
||||
fetchedTags = header.tags || [];
|
||||
|
||||
if (fetchedTags.length > 0) {
|
||||
set({
|
||||
tags: fetchedTags,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false
|
||||
});
|
||||
console.log(`[TableStore] ✅ 已加载 ${fetchedTags.length} 个标签`);
|
||||
return;
|
||||
if (response.ok) {
|
||||
const chatData = await response.json();
|
||||
const header = chatData.header || {};
|
||||
|
||||
// 读取聊天中的动态表格数据
|
||||
tableHeaders = header.tableHeaders || [];
|
||||
tableDefaults = header.tableDefaults || {};
|
||||
|
||||
if (tableHeaders.length > 0) {
|
||||
set({
|
||||
tableHeaders,
|
||||
tableDefaults,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false
|
||||
});
|
||||
console.log(`[TableStore] ✅ 已从聊天加载动态表格: ${tableHeaders.length} 个字段`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级2:从角色卡读取(降级)
|
||||
console.log('[TableStore] 聊天中无标签,从角色卡读取');
|
||||
// ✅ 优先级2:从角色卡读取(降级)
|
||||
console.log('[TableStore] 聊天中无动态表格,从角色卡读取');
|
||||
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
|
||||
|
||||
if (!charResponse.ok) throw new Error('获取角色卡失败');
|
||||
|
||||
const charData = await charResponse.json();
|
||||
fetchedTags = charData.tags || [];
|
||||
tableHeaders = charData.tableHeaders || [];
|
||||
tableDefaults = charData.tableDefaults || {};
|
||||
|
||||
set({
|
||||
tags: fetchedTags,
|
||||
tableHeaders,
|
||||
tableDefaults,
|
||||
lastUpdated: Date.now(),
|
||||
isLoading: false
|
||||
});
|
||||
console.log(`[TableStore] ✅ 已从角色卡加载 ${fetchedTags.length} 个标签`);
|
||||
console.log(`[TableStore] ✅ 已从角色卡加载动态表格: ${tableHeaders.length} 个字段`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[TableStore] 获取标签数据失败:', error);
|
||||
set({ tags: [], isLoading: false });
|
||||
console.error('[TableStore] 获取动态表格数据失败:', error);
|
||||
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: () => {
|
||||
// 清除定时器
|
||||
if (get().saveTimeoutId) {
|
||||
clearTimeout(get().saveTimeoutId);
|
||||
}
|
||||
|
||||
set({
|
||||
tags: [],
|
||||
tableHeaders: [],
|
||||
tableDefaults: {},
|
||||
currentRole: null,
|
||||
currentChat: null,
|
||||
isLoading: false,
|
||||
lastUpdated: null,
|
||||
saveTimeoutId: null
|
||||
lastUpdated: null
|
||||
});
|
||||
},
|
||||
|
||||
@@ -238,12 +125,54 @@ const useTableStore = create(
|
||||
if (!currentRole) return;
|
||||
|
||||
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
|
||||
partialize: (state) => ({
|
||||
tags: state.tags,
|
||||
tableHeaders: state.tableHeaders,
|
||||
tableDefaults: state.tableDefaults,
|
||||
currentRole: state.currentRole,
|
||||
currentChat: state.currentChat,
|
||||
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;
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
right: 0;
|
||||
background-color: var(--color-bg-primary); /* 跟随主题 */
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
z-index: 10;
|
||||
z-index: var(--z-divider); /* ✅ 基础层 - 分割线层级 */
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
padding: var(--spacing-sm);
|
||||
min-width: 220px;
|
||||
z-index: 100;
|
||||
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -579,7 +579,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||
}
|
||||
|
||||
.chat-selector-modal {
|
||||
|
||||
@@ -234,21 +234,21 @@ const ChatBox = () => {
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (message) => {
|
||||
const renderMessage = (message, index) => {
|
||||
const isUser = message.is_user;
|
||||
const isEditing = editingId === message.floor;
|
||||
|
||||
|
||||
// 判断是否为最新消息
|
||||
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||
|
||||
|
||||
// 根据消息类型设置显示名称
|
||||
const displayName = isUser ? userName : characterName;
|
||||
|
||||
|
||||
// 确定当前显示的消息内容
|
||||
let currentMes = message.mes;
|
||||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||
let currentSwipeIndex = message.swipe_id;
|
||||
|
||||
|
||||
if (hasSwipes) {
|
||||
// 如果有swipes数组
|
||||
if (currentSwipeId[message.floor] !== undefined) {
|
||||
@@ -258,14 +258,17 @@ const ChatBox = () => {
|
||||
// 否则使用默认的swipe_id
|
||||
currentSwipeIndex = message.swipe_id;
|
||||
}
|
||||
|
||||
|
||||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||
currentMes = message.swipes[currentSwipeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ✅ 使用 id 或组合 key 确保唯一性
|
||||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||
|
||||
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-header">
|
||||
<span className="message-name">{displayName}</span>
|
||||
@@ -517,9 +520,9 @@ const ChatBox = () => {
|
||||
|
||||
{characterChats.length > 0 ? (
|
||||
<div className="chat-list">
|
||||
{characterChats.map((chat, index) => (
|
||||
{characterChats.map((chat) => (
|
||||
<div
|
||||
key={index}
|
||||
key={chat.chat_name}
|
||||
className="chat-option"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
|
||||
}
|
||||
|
||||
.sidebar-tabs {
|
||||
|
||||
@@ -281,7 +281,7 @@ textarea.form-control {
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-xl);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-toast-item); /* ✅ 通知层 - Toast 项 */
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
@@ -326,7 +326,7 @@ textarea.form-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ✅ 激活状态的按钮 */
|
||||
.action-btn.active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tag 筛选器 */
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
@@ -131,6 +138,7 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示2列 */
|
||||
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
|
||||
padding: 2px;
|
||||
align-content: start; /* ✅ 从上到下排列,不留空白 */
|
||||
}
|
||||
|
||||
.character-item {
|
||||
@@ -138,7 +146,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%; /* 改为自适应宽度 */
|
||||
height: 170px; /* 减小高度,从200px降到170px */
|
||||
height: 170px; /* ✅ 固定高度,不随空间浮动 */
|
||||
background: var(--color-bg-secondary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
@@ -153,6 +161,19 @@
|
||||
-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 {
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
@@ -304,7 +325,7 @@
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -357,7 +378,7 @@
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-top: -18px; /* 使用负 margin 代替 transform,确保点击区域和视觉位置一致 */
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--color-accent);
|
||||
@@ -368,23 +389,24 @@
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: pulse 2s infinite;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translateY(-50%) scale(1);
|
||||
transform: scale(1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-toolbar:hover .toolbar-indicator {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scale(0);
|
||||
transform: scale(0);
|
||||
pointer-events: none; /* ✅ 消失时不阻挡点击 */
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
@@ -461,6 +483,18 @@
|
||||
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 {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
@@ -533,6 +567,59 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
@@ -544,7 +631,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||
}
|
||||
|
||||
.chat-selector-modal {
|
||||
|
||||
@@ -6,7 +6,7 @@ import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; //
|
||||
import './CharacterCard.css';
|
||||
|
||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||
const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
const CharacterItem = memo(({ character, isSelected, onSelect, showImages }) => {
|
||||
const handleImageError = useCallback((e) => {
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
@@ -25,7 +25,7 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`character-item ${isSelected ? 'selected' : ''}`}
|
||||
className={`character-item ${isSelected ? 'selected' : ''} ${!showImages ? 'no-image' : ''}`}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
@@ -66,7 +66,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只在必要时重新渲染
|
||||
return prevProps.character.id === nextProps.character.id &&
|
||||
prevProps.isSelected === nextProps.isSelected;
|
||||
prevProps.isSelected === nextProps.isSelected &&
|
||||
prevProps.showImages === nextProps.showImages;
|
||||
});
|
||||
|
||||
CharacterItem.displayName = 'CharacterItem';
|
||||
@@ -108,6 +109,7 @@ const CharacterCard = () => {
|
||||
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
|
||||
|
||||
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||
const [showImages, setShowImages] = useState(true); // ✅ 是否显示图片
|
||||
const fileInputRef = useRef(null);
|
||||
const clickTimeoutRef = useRef(null);
|
||||
|
||||
@@ -187,6 +189,27 @@ const CharacterCard = () => {
|
||||
const name = prompt('请输入角色名称:');
|
||||
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 {
|
||||
const { createCharacter } = useCharacterStore.getState();
|
||||
await createCharacter({
|
||||
@@ -197,7 +220,8 @@ const CharacterCard = () => {
|
||||
first_mes: '',
|
||||
mes_example: '',
|
||||
categories: [],
|
||||
tags: [] // ✅ 使用标签数组
|
||||
tags: [],
|
||||
historyMode: mode // ✅ 添加历史记录模式
|
||||
});
|
||||
} catch (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) => {
|
||||
console.log('[CharacterCard] 点击角色:', character.name);
|
||||
@@ -359,6 +439,14 @@ const CharacterCard = () => {
|
||||
<div className="tab-actions">
|
||||
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
||||
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
||||
{/* ✅ 切换图片显示 */}
|
||||
<button
|
||||
className={`action-btn ${showImages ? '' : 'active'}`}
|
||||
onClick={() => setShowImages(!showImages)}
|
||||
title={showImages ? '隐藏图片' : '显示图片'}
|
||||
>
|
||||
{showImages ? '🖼️' : '🚫'}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -495,6 +583,14 @@ const CharacterCard = () => {
|
||||
<option value="png">🖼️ PNG</option>
|
||||
<option value="json">📄 JSON</option>
|
||||
</select>
|
||||
{/* ✅ 复制角色按钮 */}
|
||||
<button
|
||||
className="toolbar-btn duplicate"
|
||||
onClick={() => handleDuplicate(selectedCharacter)}
|
||||
title="复制角色"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
|
||||
📤
|
||||
</button>
|
||||
@@ -613,7 +709,7 @@ const CharacterCard = () => {
|
||||
value={editForm.worldInfoId || ''}
|
||||
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
|
||||
>
|
||||
<option value="">不绑定</option>
|
||||
<option key="none" value="">不绑定</option>
|
||||
{worldBooks.map(book => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
@@ -623,6 +719,97 @@ const CharacterCard = () => {
|
||||
<small className="form-hint">💡 选择后,该角色将自动加载绑定的世界书内容</small>
|
||||
</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">
|
||||
<label>动态表格数据 (SillyTavern 关键字机制)</label>
|
||||
<input
|
||||
@@ -679,6 +866,7 @@ const CharacterCard = () => {
|
||||
character={char}
|
||||
isSelected={selectedCharacter?.id === char.id}
|
||||
onSelect={handleSelectCharacter}
|
||||
showImages={showImages}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -712,10 +900,10 @@ const CharacterCard = () => {
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))} // ✅ 使用 store 方法
|
||||
>
|
||||
<option value={8}>8条/页</option>
|
||||
<option value={12}>12条/页</option>
|
||||
<option value={20}>20条/页</option>
|
||||
<option value={50}>50条/页</option>
|
||||
<option key="8" value={8}>8条/页</option>
|
||||
<option key="12" value={12}>12条/页</option>
|
||||
<option key="20" value={20}>20条/页</option>
|
||||
<option key="50" value={50}>50条/页</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-tooltip); /* ✅ 组件层 - 悬浮提示 */
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translate(-50%, -100%);
|
||||
@@ -100,15 +100,13 @@
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
position: fixed;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
|
||||
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||
min-width: 140px;
|
||||
padding: 4px;
|
||||
}
|
||||
@@ -139,6 +137,15 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdown-item.delete-item {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.dropdown-item.delete-item:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
color: #c82333;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.preset-save-dialog,
|
||||
.preset-edit-dialog,
|
||||
@@ -151,7 +158,7 @@
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal-content); /* ✅ 弹窗层 - 对话框内容 */
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
@@ -282,6 +289,49 @@
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
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 {
|
||||
@@ -695,6 +745,41 @@
|
||||
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 {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-bg-primary);
|
||||
|
||||
@@ -24,6 +24,7 @@ const PresetPanel = () => {
|
||||
addComponent,
|
||||
removeComponent,
|
||||
moveComponent,
|
||||
saveComponentOrder,
|
||||
setCurrentPage,
|
||||
setPageSize,
|
||||
getCurrentPagePresets,
|
||||
@@ -44,12 +45,19 @@ const PresetPanel = () => {
|
||||
// 组件编辑状态
|
||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||
const [editComponentContent, setEditComponentContent] = useState('');
|
||||
const [editComponentRole, setEditComponentRole] = useState(0); // 0=system, 1=user, 2=ai
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// 拖拽状态
|
||||
const [draggedItem, setDraggedItem] = useState(null);
|
||||
const [dragOverItem, setDragOverItem] = useState(null);
|
||||
|
||||
// 下拉菜单位置
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, right: 0 });
|
||||
|
||||
// ✅ 页码跳转输入
|
||||
const [jumpPageInput, setJumpPageInput] = useState('');
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -63,6 +71,20 @@ const PresetPanel = () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [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();
|
||||
@@ -134,6 +156,116 @@ const PresetPanel = () => {
|
||||
// 加载预设列表 - 由 SideBarLeft 标签切换时触发
|
||||
// 注意: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 () => {
|
||||
if (newPresetName.trim()) {
|
||||
@@ -398,11 +530,19 @@ const PresetPanel = () => {
|
||||
};
|
||||
|
||||
// 放置
|
||||
const handleDrop = (e, index) => {
|
||||
const handleDrop = async (e, index) => {
|
||||
e.preventDefault();
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
|
||||
moveComponent(draggedItem, index);
|
||||
|
||||
// ✅ 拖拽后保存排序到后端
|
||||
try {
|
||||
await saveComponentOrder();
|
||||
} catch (error) {
|
||||
console.error('Failed to save component order:', error);
|
||||
alert('保存排序失败: ' + error.message);
|
||||
}
|
||||
|
||||
setDraggedItem(null);
|
||||
setDragOverItem(null);
|
||||
@@ -448,34 +588,35 @@ const PresetPanel = () => {
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<div className="dropdown-menu">
|
||||
<div
|
||||
className="dropdown-menu"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
right: `${dropdownPosition.right}px`
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
title="将当前的参数设置和组件配置保存为新的预设"
|
||||
title="创建一个全新的空白预设"
|
||||
onClick={() => {
|
||||
setShowSaveDialog(true);
|
||||
handleCreateEmptyPreset();
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
💾 保存为新预设
|
||||
➕ 新建空预设
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
disabled={!selectedPreset}
|
||||
title="修改当前选中预设的名称"
|
||||
title="保存当前的参数和组件配置到当前预设"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
if (preset) {
|
||||
setEditPresetId(selectedPreset);
|
||||
setEditPresetName(preset.name);
|
||||
setShowEditDialog(true);
|
||||
}
|
||||
handleSaveCurrentPreset();
|
||||
}
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
✏️ 编辑名称
|
||||
💾 保存当前预设
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
@@ -485,7 +626,7 @@ const PresetPanel = () => {
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
📥 导入预设
|
||||
📥 导入
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
@@ -496,7 +637,20 @@ const PresetPanel = () => {
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
📤 导出预设
|
||||
📤 导出
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item delete-item"
|
||||
disabled={!selectedPreset}
|
||||
title="删除当前选中的预设"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
handleDeletePreset(selectedPreset);
|
||||
}
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -561,6 +715,22 @@ const PresetPanel = () => {
|
||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||
</div>
|
||||
<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
|
||||
value={editComponentContent}
|
||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||
@@ -818,10 +988,8 @@ const PresetPanel = () => {
|
||||
draggable={!component.marker}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
>
|
||||
<div className="component-header">
|
||||
<div className="component-controls">
|
||||
@@ -870,7 +1038,10 @@ const PresetPanel = () => {
|
||||
{/* 最后一个拖拽指示器 - 在列表末尾 */}
|
||||
<div
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
@@ -889,6 +1060,31 @@ const PresetPanel = () => {
|
||||
上一页
|
||||
</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">
|
||||
第 {currentPage} / {totalPages} 页
|
||||
</span>
|
||||
|
||||
@@ -1,94 +1,27 @@
|
||||
/* ==================== SillyTavern 风格系统设置 ==================== */
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
background: var(--bg-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;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.message {
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
margin: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
animation: slideDown 0.2s ease;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
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 {
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -96,135 +29,313 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
.message.success {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
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;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
.tab-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 设置项 */
|
||||
.setting-item {
|
||||
margin-bottom: 20px;
|
||||
.tab-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 右侧主内容区 */
|
||||
.settings-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-muted);
|
||||
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%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.setting-item input[type="text"]:focus {
|
||||
.text-input:focus,
|
||||
.file-input:focus {
|
||||
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 {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
.hint-text {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.setting-item code {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
/* 路径代码 */
|
||||
.path-code {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 文件结构列表 */
|
||||
.file-structure {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
.file-structure-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.file-structure li {
|
||||
padding: 4px 0;
|
||||
.file-structure-list li {
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 预览框 */
|
||||
.preview-box {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
.folder-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-box .original,
|
||||
.preview-box .processed {
|
||||
/* 预览容器 */
|
||||
.preview-container {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-box .original:last-child,
|
||||
.preview-box .processed:last-child {
|
||||
.preview-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-box strong {
|
||||
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;
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn-primary {
|
||||
.preview-code {
|
||||
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;
|
||||
background: var(--accent-color);
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
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) {
|
||||
background: var(--accent-hover);
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover, #5a67d8);
|
||||
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;
|
||||
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 (
|
||||
<div className="settings-container">
|
||||
{/* 标题 */}
|
||||
<div className="settings-header">
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message.text && (
|
||||
<div className={`message ${message.type}`}>
|
||||
@@ -93,171 +88,216 @@ const Settings = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页标签 */}
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('regex')}
|
||||
>
|
||||
正则规则
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('thinking')}
|
||||
>
|
||||
思考标签
|
||||
</button>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
通用设置
|
||||
</button>
|
||||
</div>
|
||||
{/* SillyTavern 风格:左右分栏布局 */}
|
||||
<div className="settings-layout">
|
||||
{/* 左侧:垂直标签栏 */}
|
||||
<div className="settings-sidebar">
|
||||
<button
|
||||
className={`sidebar-tab ${activeTab === 'regex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('regex')}
|
||||
>
|
||||
<span className="tab-icon">📝</span>
|
||||
<span className="tab-label">正则规则</span>
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-tab ${activeTab === 'thinking' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('thinking')}
|
||||
>
|
||||
<span className="tab-icon">💭</span>
|
||||
<span className="tab-label">思考标签</span>
|
||||
</button>
|
||||
<button
|
||||
className={`sidebar-tab ${activeTab === 'general' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
>
|
||||
<span className="tab-icon">⚙️</span>
|
||||
<span className="tab-label">通用设置</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="settings-content">
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
{/* 右侧:内容区域 */}
|
||||
<div className="settings-main">
|
||||
{loading && <div className="loading">加载中...</div>}
|
||||
|
||||
{/* 正则规则设置 */}
|
||||
{activeTab === 'regex' && (
|
||||
<div className="settings-section">
|
||||
<h3>正则规则管理</h3>
|
||||
<p className="section-description">
|
||||
管理文本处理规则,支持 SillyTavern 格式导入
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>规则文件位置</label>
|
||||
<code>data/regex/</code>
|
||||
<ul className="file-structure">
|
||||
<li>📁 global/ - 全局规则</li>
|
||||
<li>📁 characters/ - 角色卡规则</li>
|
||||
<li>📁 presets/ - 预设规则</li>
|
||||
</ul>
|
||||
</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>
|
||||
{/* 正则规则设置 */}
|
||||
{activeTab === 'regex' && (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>📝 正则规则管理</h3>
|
||||
<p className="panel-description">
|
||||
管理文本处理规则,支持 SillyTavern 格式导入
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="group-title">规则文件结构</div>
|
||||
<div className="setting-row">
|
||||
<div className="row-label">存储位置</div>
|
||||
<div className="row-content">
|
||||
<code className="path-code">data/regex/</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="processed">
|
||||
<strong>处理后:</strong>
|
||||
<p>这是回复 继续</p>
|
||||
<ul className="file-structure-list">
|
||||
<li><span className="folder-icon">📁</span> global/ - 全局规则</li>
|
||||
<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 className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 思考标签配置 */}
|
||||
{activeTab === 'thinking' && (
|
||||
<div className="settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>💭 思考标签配置</h3>
|
||||
<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>
|
||||
|
||||
{/* 通用设置 */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="settings-section">
|
||||
<h3>通用设置</h3>
|
||||
<p className="section-description">
|
||||
其他系统级配置
|
||||
</p>
|
||||
|
||||
<div className="setting-item">
|
||||
<label htmlFor="current-preset">当前预设</label>
|
||||
<input
|
||||
id="current-preset"
|
||||
type="text"
|
||||
value={settings.currentPresetName || ''}
|
||||
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
|
||||
placeholder="未选择"
|
||||
/>
|
||||
<small>影响全局正则规则的启用</small>
|
||||
</div>
|
||||
<div className="setting-row">
|
||||
<div className="row-label">后缀</div>
|
||||
<div className="row-content">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.thinkingTagSuffix}
|
||||
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
|
||||
placeholder="</thinking>"
|
||||
className="text-input"
|
||||
/>
|
||||
<small className="hint-text">默认值:</thinking></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
<div className="settings-group">
|
||||
<div className="group-title">效果预览</div>
|
||||
<div className="preview-container">
|
||||
<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>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* 通用设置 */}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
overflow: hidden;
|
||||
margin-top: 8px; /* 与全局世界书保持间距 */
|
||||
position: relative; /* 确保正常文档流 */
|
||||
z-index: 1; /* 降低层级,不遮挡其他元素 */
|
||||
z-index: var(--z-base-content); /* ✅ 基础内容层 */
|
||||
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
|
||||
min-height: 307px;
|
||||
display: flex;
|
||||
@@ -258,17 +258,14 @@
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: fixed;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@@ -295,6 +292,33 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
@@ -321,6 +345,41 @@
|
||||
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 {
|
||||
padding: 3px 6px;
|
||||
font-size: 11px;
|
||||
@@ -634,12 +693,13 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9998;
|
||||
background-color: rgba(0, 0, 0, 0.3); /* ✅ 降低透明度,不使用模糊 */
|
||||
/* backdrop-filter: blur(4px); */ /* ✅ 移除模糊效果,允许操作旁边区域 */
|
||||
z-index: var(--z-edit-panel-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
pointer-events: none; /* ✅ 允许点击穿透到下层元素 */
|
||||
}
|
||||
|
||||
.edit-panel-overlay.open {
|
||||
@@ -657,7 +717,7 @@
|
||||
height: 85vh;
|
||||
max-height: 900px;
|
||||
background: var(--color-bg-primary);
|
||||
z-index: 9999;
|
||||
z-index: var(--z-edit-panel-content); /* ✅ 弹窗层 - 编辑面板 */
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
@@ -764,6 +824,7 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch; /* ✅ 确保所有子元素高度一致 */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -775,22 +836,30 @@
|
||||
.form-group.compact {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ✅ 统一输入框和选择框的高度 */
|
||||
.form-input {
|
||||
padding: 10px 12px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
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 {
|
||||
@@ -804,6 +873,16 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
@@ -817,6 +896,39 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
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);
|
||||
}
|
||||
|
||||
.trigger-selector {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.trigger-config-panel {
|
||||
flex: 2;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.keyword-config {
|
||||
/* ✅ 条件触发配置样式 */
|
||||
.condition-config {
|
||||
display: flex;
|
||||
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;
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
position: fixed;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1100;
|
||||
z-index: var(--z-sort-panel); /* ✅ 组件层 - 排序面板 */
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import ReactDOM from 'react-dom'; // ✅ 用于 Portal
|
||||
import './WorldBook.css';
|
||||
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice';
|
||||
@@ -76,25 +77,34 @@ const WorldBook = () => {
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20); // 默认每页20条
|
||||
const [jumpPageInput, setJumpPageInput] = useState(''); // ✅ 页码跳转输入
|
||||
|
||||
// ✅ 排序状态
|
||||
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
|
||||
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
|
||||
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
|
||||
|
||||
// 下拉菜单位置
|
||||
const [sortDropdownPosition, setSortDropdownPosition] = useState({ top: 0, right: 0 });
|
||||
const [worldbookDropdownPosition, setWorldbookDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
// ✅ 点击外部关闭排序下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
|
||||
setShowSortMenu(false);
|
||||
}
|
||||
// ✅ 点击外部关闭世界书下拉菜单
|
||||
if (showWorldBookDropdown && !event.target.closest('.worldbook-dropdown-container')) {
|
||||
setShowWorldBookDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showSortMenu]);
|
||||
}, [showSortMenu, showWorldBookDropdown]);
|
||||
|
||||
// 获取当前激活的分页
|
||||
const { activeTab } = useSideBarLeftStore();
|
||||
@@ -267,6 +277,7 @@ const WorldBook = () => {
|
||||
|
||||
const handleEntryClick = (entry) => {
|
||||
setCurrentEntry(entry);
|
||||
setLocalEntry(entry); // ✅ 设置本地状态以在编辑面板中显示
|
||||
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) => {
|
||||
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后自动保存
|
||||
const debouncedSave = React.useCallback((updatedEntry) => {
|
||||
if (saveTimeoutRef.current) {
|
||||
@@ -710,14 +765,33 @@ const WorldBook = () => {
|
||||
|
||||
{/* 世界书选择区域 */}
|
||||
<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"
|
||||
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 : '选择世界书'}
|
||||
<span>▼</span>
|
||||
</button>
|
||||
{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 => (
|
||||
<div
|
||||
key={book.name}
|
||||
@@ -794,7 +868,7 @@ const WorldBook = () => {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('position', parseInt(e.target.value));
|
||||
handleEntryQuickUpdate(entry, 'position', parseInt(e.target.value));
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2, 3, 4, 5].map(pos => {
|
||||
@@ -813,7 +887,8 @@ const WorldBook = () => {
|
||||
title={isEnabled ? '已启用' : '已禁用'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEntryUpdate('disable', !isEnabled);
|
||||
// ✅ 直接更新条目,使用 entry 而不是 currentEntry
|
||||
handleEntryQuickUpdate(entry, 'disable', !isEnabled);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -871,7 +946,8 @@ const WorldBook = () => {
|
||||
|
||||
{/* 编辑面板遮罩层 */}
|
||||
<div className={`edit-panel-overlay ${showEditPanel ? 'open' : ''}`}
|
||||
onClick={() => setShowEditPanel(false)}/>
|
||||
/* ✅ 移除 onClick,允许点击穿透到下层元素(动态表格等) */
|
||||
/>
|
||||
|
||||
{/* 编辑面板 */}
|
||||
{showEditPanel && localEntry && (
|
||||
@@ -879,7 +955,11 @@ const WorldBook = () => {
|
||||
{/* 头部 */}
|
||||
<div className="edit-panel-header">
|
||||
<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>
|
||||
</div>
|
||||
@@ -1183,12 +1263,12 @@ const WorldBook = () => {
|
||||
|
||||
{activeTriggerStrategy === 'condition' && (
|
||||
<div className="condition-config">
|
||||
<div className="form-group compact">
|
||||
<label className="form-label">变量A</label>
|
||||
<input
|
||||
type="text"
|
||||
{/* 逻辑运算符选择 */}
|
||||
<div className="form-group compact" style={{marginBottom: '8px'}}>
|
||||
<label className="form-label">逻辑关系</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.logic || 'AND'}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
@@ -1198,74 +1278,214 @@ const WorldBook = () => {
|
||||
true,
|
||||
{
|
||||
...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">
|
||||
<label className="form-label">运算符</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...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
|
||||
{/* 条件 A */}
|
||||
<div className="condition-block">
|
||||
<div className="condition-header">
|
||||
<span className="condition-label">条件 A</span>
|
||||
</div>
|
||||
<div className="condition-fields">
|
||||
<input
|
||||
type="text"
|
||||
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 || ''}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
condition: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||
variable_b: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleEntryUpdate('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const updatedTriggerConfig = {
|
||||
...currentEntry.trigger_config,
|
||||
triggers: {
|
||||
...currentEntry.trigger_config?.triggers,
|
||||
condition: [
|
||||
true,
|
||||
{
|
||||
...currentEntry.trigger_config?.triggers?.condition?.[1],
|
||||
variable_b: e.target.value
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
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>
|
||||
@@ -1286,13 +1506,21 @@ const WorldBook = () => {
|
||||
<div style={{flex: 1}}/>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setShowEditPanel(false)}
|
||||
onClick={() => {
|
||||
setShowEditPanel(false);
|
||||
setLocalEntry(null); // ✅ 清空本地状态
|
||||
setCurrentEntry(null); // ✅ 清空当前条目
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowEditPanel(false)}
|
||||
onClick={() => {
|
||||
setShowEditPanel(false);
|
||||
setLocalEntry(null); // ✅ 清空本地状态
|
||||
setCurrentEntry(null); // ✅ 清空当前条目
|
||||
}}
|
||||
>
|
||||
保存并关闭
|
||||
</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;
|
||||
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 = () => {
|
||||
// ✅ 从 Zustand store 获取状态和方法
|
||||
const {
|
||||
tags,
|
||||
tableHeaders,
|
||||
tableDefaults,
|
||||
isLoading,
|
||||
currentRole,
|
||||
currentChat,
|
||||
loadTags,
|
||||
updateTags,
|
||||
addTag,
|
||||
deleteTag,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
editTag,
|
||||
refresh
|
||||
refresh,
|
||||
updateTableData // ✅ 新增:保存方法
|
||||
} = useTableStore();
|
||||
|
||||
// 本地 UI 状态(不需要持久化)
|
||||
const [editingIndex, setEditingIndex] = useState(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [newTagInput, setNewTagInput] = useState('');
|
||||
// 本地编辑状态
|
||||
const [editingValues, setEditingValues] = useState({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 监听角色/聊天变化,自动加载数据
|
||||
useEffect(() => {
|
||||
if (currentRole) {
|
||||
loadTags(currentRole, currentChat);
|
||||
setEditingValues({}); // 重置编辑状态
|
||||
}
|
||||
}, [currentRole, currentChat, loadTags]);
|
||||
|
||||
// 开始编辑标签
|
||||
const handleStartEdit = (index, value) => {
|
||||
setEditingIndex(index);
|
||||
setEditValue(value);
|
||||
// ✅ 处理值变化
|
||||
const handleValueChange = (key, value) => {
|
||||
setEditingValues(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 保存编辑
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null) return;
|
||||
// ✅ 保存单个字段(失焦时自动保存)
|
||||
const handleBlur = async (key) => {
|
||||
const newValue = editingValues[key];
|
||||
if (newValue === undefined || newValue === tableDefaults[key]) {
|
||||
return; // 没有变化,不保存
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
editTag(editingIndex, editValue);
|
||||
setEditingIndex(null);
|
||||
setEditValue('');
|
||||
// 更新 tableDefaults
|
||||
const newTableDefaults = {
|
||||
...tableDefaults,
|
||||
[key]: newValue
|
||||
};
|
||||
|
||||
await updateTableData({ tableDefaults: newTableDefaults });
|
||||
|
||||
// 清除编辑状态
|
||||
setEditingValues(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[key];
|
||||
return newState;
|
||||
});
|
||||
|
||||
setIsSaving(false);
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditingIndex(null);
|
||||
setEditValue('');
|
||||
};
|
||||
|
||||
// 添加新标签(支持批量输入)
|
||||
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('');
|
||||
// ✅ 按 Enter 键保存
|
||||
const handleKeyDown = (e, key) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur(); // 触发 blur 事件
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有角色,显示提示
|
||||
@@ -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 (
|
||||
<div className="table-panel">
|
||||
<div className="tab-header">
|
||||
<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 className="table-container">
|
||||
{/* 标签列表 */}
|
||||
<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 className="empty-table">
|
||||
<p>📊</p>
|
||||
<p>该角色没有动态表格数据</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: 100; /* 降低层级,不遮挡悬浮提示 */
|
||||
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar */
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ const Toolbar = () => {
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content settings-panel">
|
||||
<div className="panel-header">
|
||||
<h3>系统设置</h3>
|
||||
<h2>系统设置</h2>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Import global styles */
|
||||
@import './styles/variables.css';
|
||||
@import './styles/reset.css';
|
||||
@import './styles/z-index.css'; /* ✅ Z-Index 层级规范 */
|
||||
|
||||
/* ==================== Main Layout ==================== */
|
||||
.app {
|
||||
@@ -128,12 +129,12 @@
|
||||
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%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
z-index: var(--z-background); /* ✅ 基础层 - 背景 */
|
||||
}
|
||||
|
||||
.chat-area > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: var(--z-base-content); /* ✅ 基础层 - 内容 */
|
||||
}
|
||||
|
||||
.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