添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现

This commit is contained in:
2026-05-05 03:01:20 +08:00
parent 2050a30a52
commit adb59da06d
46 changed files with 4484 additions and 882 deletions

173
CHAT_SUMMARY_TEST_GUIDE.md Normal file
View 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`

View 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个插入位置
- ✅ 详细统计分析(按位置和触发类型)
- ✅ 完整日志记录(便于调试)
这为动态上下文管理提供了强大的基础,可以根据对话内容智能地注入相关知识。

View File

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

View 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))

View File

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

View File

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

View File

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

View 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
)

View File

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

View File

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

View File

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

View 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()

View File

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

View File

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

View File

@@ -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 分组插入

View 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()

View File

@@ -216,5 +216,5 @@
"continue_postfix": " ",
"seed": -1,
"n": 1,
"updatedAt": 1777640905
"updatedAt": 1777857993
}

218
frontend/Z_INDEX_GUIDE.md Normal file
View 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 层级体系 |

View File

@@ -114,7 +114,7 @@ function App() {
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
}
} else {
console.log('[App] ✅ API 配置已从缓存恢复');
// 从缓存恢复
}
} catch (err) {
console.error('[App] ❌ API 配置加载失败:', err);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
}
.sidebar-tabs {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>默认值&lt;thinking&gt;</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>默认值&lt;/thinking&gt;</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="&lt;thinking&gt;"
className="text-input"
/>
<small className="hint-text">默认值&lt;thinking&gt;</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="&lt;/thinking&gt;"
className="text-input"
/>
<small className="hint-text">默认值&lt;/thinking&gt;</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>
);

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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
View 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
View 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()