From adb59da06da3435268cbdc14efc368d8ee67615d Mon Sep 17 00:00:00 2001 From: moranzhi Date: Tue, 5 May 2026 03:01:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95=E3=80=81?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=80=BB=E7=BB=93=E3=80=81=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E3=80=81rag=EF=BC=88todo=EF=BC=893=E7=A7=8D=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=BF=9D=E5=AD=98=E6=96=B9=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHAT_SUMMARY_TEST_GUIDE.md | 173 +++++++ WORLDBOOK_ACTIVATION_LOGIC.md | 292 ++++++++++++ backend/api/route.py | 3 +- backend/api/routes/chatSummaryRoute.py | 154 +++++++ backend/api/routes/chatWsRoute.py | 6 +- backend/api/routes/presetsRoute.py | 49 +- backend/models/internal.py | 49 +- backend/models/summary_message.py | 76 ++++ backend/requirements.txt | 1 + backend/services/character_service.py | 4 + backend/services/chat_service.py | 78 ++++ backend/services/chat_summary_service.py | 213 +++++++++ backend/services/chat_workflow_service.py | 258 +++++++++-- backend/services/preset_service.py | 39 ++ backend/services/prompt_assembler.py | 11 +- create_demon_girlfriend_test.py | 193 ++++++++ data/preset/Default.json | 2 +- frontend/Z_INDEX_GUIDE.md | 218 +++++++++ frontend/src/App.jsx | 2 +- frontend/src/Store/Mid/ChatBoxSlice.jsx | 1 + .../SideBarLeft/CharacterCardUISlice.jsx | 5 +- .../src/Store/SideBarLeft/PresetSlice.jsx | 73 ++- .../src/Store/SideBarRight/TableSlice.jsx | 288 +++++------- .../src/components/Mid/ChatBox/ChatBox.css | 6 +- .../src/components/Mid/ChatBox/ChatBox.jsx | 23 +- .../components/SideBarLeft/SideBarLeft.css | 2 +- .../SideBarLeft/tabs/ApiConfig/ApiConfig.css | 4 +- .../tabs/CharacterCard/CharacterCard.css | 103 ++++- .../tabs/CharacterCard/CharacterCard.jsx | 206 ++++++++- .../SideBarLeft/tabs/Presets/Presets.css | 97 +++- .../SideBarLeft/tabs/Presets/Presets.jsx | 236 +++++++++- .../SideBarLeft/tabs/Settings/Settings.css | 425 +++++++++++------- .../SideBarLeft/tabs/Settings/Settings.jsx | 360 ++++++++------- .../SideBarLeft/tabs/WorldBook/WorldBook.css | 272 +++++++++-- .../SideBarLeft/tabs/WorldBook/WorldBook.jsx | 376 +++++++++++++--- .../SideBarRight/tabs/Settings/Settings.jsx | 229 ++++++++++ .../SideBarRight/tabs/Table/Table.css | 101 +++++ .../SideBarRight/tabs/Table/Table.jsx | 247 +++++----- frontend/src/components/TopBar/TopBar.css | 2 +- frontend/src/components/TopBar/TopBar.jsx | 2 +- frontend/src/index.css | 5 +- frontend/src/styles/z-index.css | 39 ++ frontend/src/styles/z-index.ts | 109 +++++ frontend/src/utils/summaryCounter.js | 159 +++++++ test_character_update.py | 99 ++++ test_chat_summary.py | 76 ++++ 46 files changed, 4484 insertions(+), 882 deletions(-) create mode 100644 CHAT_SUMMARY_TEST_GUIDE.md create mode 100644 WORLDBOOK_ACTIVATION_LOGIC.md create mode 100644 backend/api/routes/chatSummaryRoute.py create mode 100644 backend/models/summary_message.py create mode 100644 backend/services/chat_summary_service.py create mode 100644 create_demon_girlfriend_test.py create mode 100644 frontend/Z_INDEX_GUIDE.md create mode 100644 frontend/src/components/SideBarRight/tabs/Settings/Settings.jsx create mode 100644 frontend/src/styles/z-index.css create mode 100644 frontend/src/styles/z-index.ts create mode 100644 frontend/src/utils/summaryCounter.js create mode 100644 test_character_update.py create mode 100644 test_chat_summary.py diff --git a/CHAT_SUMMARY_TEST_GUIDE.md b/CHAT_SUMMARY_TEST_GUIDE.md new file mode 100644 index 0000000..851dbf7 --- /dev/null +++ b/CHAT_SUMMARY_TEST_GUIDE.md @@ -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` diff --git a/WORLDBOOK_ACTIVATION_LOGIC.md b/WORLDBOOK_ACTIVATION_LOGIC.md new file mode 100644 index 0000000..4c6cf19 --- /dev/null +++ b/WORLDBOOK_ACTIVATION_LOGIC.md @@ -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 => ( +
+
+ {positionLabels[pos]} ({pos}) +
+ {/* 渲染该位置的所有条目 */} +
+))} +``` + +## 扩展方向 + +### 短期优化 +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个插入位置) +- ✅ 详细统计分析(按位置和触发类型) +- ✅ 完整日志记录(便于调试) + +这为动态上下文管理提供了强大的基础,可以根据对话内容智能地注入相关知识。 diff --git a/backend/api/route.py b/backend/api/route.py index d4c8e3a..9434b12 100644 --- a/backend/api/route.py +++ b/backend/api/route.py @@ -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) diff --git a/backend/api/routes/chatSummaryRoute.py b/backend/api/routes/chatSummaryRoute.py new file mode 100644 index 0000000..56da047 --- /dev/null +++ b/backend/api/routes/chatSummaryRoute.py @@ -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)) diff --git a/backend/api/routes/chatWsRoute.py b/backend/api/routes/chatWsRoute.py index 92b2999..980ff98 100644 --- a/backend/api/routes/chatWsRoute.py +++ b/backend/api/routes/chatWsRoute.py @@ -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( diff --git a/backend/api/routes/presetsRoute.py b/backend/api/routes/presetsRoute.py index 2220ef3..9815dcd 100644 --- a/backend/api/routes/presetsRoute.py +++ b/backend/api/routes/presetsRoute.py @@ -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)) diff --git a/backend/models/internal.py b/backend/models/internal.py index ed8b4f1..902f22b 100644 --- a/backend/models/internal.py +++ b/backend/models/internal.py @@ -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): diff --git a/backend/models/summary_message.py b/backend/models/summary_message.py new file mode 100644 index 0000000..5f3c65d --- /dev/null +++ b/backend/models/summary_message.py @@ -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 + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 516b4f9..0abf01e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/services/character_service.py b/backend/services/character_service.py index 8161927..a856aa4 100644 --- a/backend/services/character_service.py +++ b/backend/services/character_service.py @@ -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, diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index ae032de..781db8a 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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)) diff --git a/backend/services/chat_summary_service.py b/backend/services/chat_summary_service.py new file mode 100644 index 0000000..9e46968 --- /dev/null +++ b/backend/services/chat_summary_service.py @@ -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() diff --git a/backend/services/chat_workflow_service.py b/backend/services/chat_workflow_service.py index 11d8f2e..9dc2f57 100644 --- a/backend/services/chat_workflow_service.py +++ b/backend/services/chat_workflow_service.py @@ -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 diff --git a/backend/services/preset_service.py b/backend/services/preset_service.py index 2c8b3e5..6542a16 100644 --- a/backend/services/preset_service.py +++ b/backend/services/preset_service.py @@ -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 diff --git a/backend/services/prompt_assembler.py b/backend/services/prompt_assembler.py index 7c80383..997d5ad 100644 --- a/backend/services/prompt_assembler.py +++ b/backend/services/prompt_assembler.py @@ -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 分组插入 diff --git a/create_demon_girlfriend_test.py b/create_demon_girlfriend_test.py new file mode 100644 index 0000000..1879b9e --- /dev/null +++ b/create_demon_girlfriend_test.py @@ -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() diff --git a/data/preset/Default.json b/data/preset/Default.json index 9299a2d..8a5f4ab 100644 --- a/data/preset/Default.json +++ b/data/preset/Default.json @@ -216,5 +216,5 @@ "continue_postfix": " ", "seed": -1, "n": 1, - "updatedAt": 1777640905 + "updatedAt": 1777857993 } \ No newline at end of file diff --git a/frontend/Z_INDEX_GUIDE.md b/frontend/Z_INDEX_GUIDE.md new file mode 100644 index 0000000..3293d9c --- /dev/null +++ b/frontend/Z_INDEX_GUIDE.md @@ -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 +
+``` + +--- + +## 📚 相关文件 + +- **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 层级体系 | diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 18cb075..dac9d2c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -114,7 +114,7 @@ function App() { console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建'); } } else { - console.log('[App] ✅ API 配置已从缓存恢复'); + // 从缓存恢复 } } catch (err) { console.error('[App] ❌ API 配置加载失败:', err); diff --git a/frontend/src/Store/Mid/ChatBoxSlice.jsx b/frontend/src/Store/Mid/ChatBoxSlice.jsx index 66c9a1d..818c38c 100644 --- a/frontend/src/Store/Mid/ChatBoxSlice.jsx +++ b/frontend/src/Store/Mid/ChatBoxSlice.jsx @@ -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, diff --git a/frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx b/frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx index c903da4..1461b42 100644 --- a/frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx +++ b/frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx @@ -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 || {} } }); }, diff --git a/frontend/src/Store/SideBarLeft/PresetSlice.jsx b/frontend/src/Store/SideBarLeft/PresetSlice.jsx index a512252..d16c3be 100644 --- a/frontend/src/Store/SideBarLeft/PresetSlice.jsx +++ b/frontend/src/Store/SideBarLeft/PresetSlice.jsx @@ -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; diff --git a/frontend/src/Store/SideBarRight/TableSlice.jsx b/frontend/src/Store/SideBarRight/TableSlice.jsx index 9d279a4..9e21832 100644 --- a/frontend/src/Store/SideBarRight/TableSlice.jsx +++ b/frontend/src/Store/SideBarRight/TableSlice.jsx @@ -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; diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.css b/frontend/src/components/Mid/ChatBox/ChatBox.css index 265db8b..2661915 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.css +++ b/frontend/src/components/Mid/ChatBox/ChatBox.css @@ -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 { diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.jsx b/frontend/src/components/Mid/ChatBox/ChatBox.jsx index d583c8a..1ef6df7 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.jsx +++ b/frontend/src/components/Mid/ChatBox/ChatBox.jsx @@ -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 ( -
+
{displayName} @@ -517,9 +520,9 @@ const ChatBox = () => { {characterChats.length > 0 ? (
- {characterChats.map((chat, index) => ( + {characterChats.map((chat) => (
{ +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 (
{ }, (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 = () => {
+ {/* ✅ 切换图片显示 */} + { + {/* ✅ 复制角色按钮 */} + @@ -613,7 +709,7 @@ const CharacterCard = () => { value={editForm.worldInfoId || ''} onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)} > - + {worldBooks.map(book => (
+ {/* ✅ 历史记录总结设置 */} +
+ + + + 全量模式:保留所有消息 | 总结模式:自动总结旧消息 | RAG模式:向量检索(需配置API) + +
+ + {/* ✅ 总结配置(仅在summary模式下显示) */} + {(editForm.historyMode === 'summary' || editForm.summaryConfig) && ( +
+ +
+
+ + { + const config = editForm.summaryConfig || {}; + handleFormChange('summaryConfig', { + ...config, + interval: Number(e.target.value) + }); + }} + /> +
+
+ + { + const config = editForm.summaryConfig || {}; + handleFormChange('summaryConfig', { + ...config, + recentFloorsToKeep: Number(e.target.value) + }); + }} + /> +
+
+ +
+
+ +