7 Commits

154 changed files with 22732 additions and 6727 deletions

5
.env
View File

@@ -9,8 +9,3 @@ REGEX_FILE=/data/regex_rules.json
COMFYUI_API_URL=http://comfyui:8188
BACKEND_PORT=8000
FRONTEND_PORT=8501
# 先配置 .env 文件
MAIN_LLM_API_KEY=sk-Oh4o3fzV6Qe59B6DRwSskE48xe5D6bq1hkgDZqH1mmJOCN8j
MAIN_LLM_BASE_URL=https://api.chatfire.cn/v1
MAIN_LLM_MODEL=glm4.7

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ==================== 路径配置 ====================
VECTORSTORE_PATH=/data/vectorstore
STATE_FILE=/data/state.json
SCHEMA_FILE=/data/schema.json
PRESETS_FILE=/data/presets.json
REGEX_FILE=/data/regex_rules.json
# ==================== 服务地址 ====================
COMFYUI_API_URL=http://comfyui:8188
BACKEND_PORT=8000
FRONTEND_PORT=8501
# ==================== API 加密密钥 ====================
# ⚠️ 重要:此密钥用于加密存储在配置文件中的 API Keys
# ⚠️ 生产环境必须设置此变量,否则每次重启后无法解密之前的 API Key
# ⚠️ 生成方法python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
API_ENCRYPTION_KEY=your-encryption-key-here
# ==================== 默认 LLM 配置(可选)====================
# 这些配置仅用于测试,实际使用时请通过 API 配置页面设置
# MAIN_LLM_API_KEY=sk-your-api-key
# MAIN_LLM_BASE_URL=https://api.openai.com/v1
# MAIN_LLM_MODEL=gpt-4

157
.gitignore vendored
View File

@@ -29,7 +29,21 @@ env/
.venv
VENV/
# IDE
# Python test files (temporary)
test_*.py
check_*.py
clear_*.py
convert_*.py
generate_*.py
create_*.py
test.py
# Python type checking
.mypy_cache/
.pytest_cache/
.ruff_cache/
# ==================== IDE ====================
.vscode/
.idea/
*.swp
@@ -38,14 +52,39 @@ VENV/
.project
.pydevproject
.settings/
*.sublime-project
*.sublime-workspace
*.iml
.cursor/
.windsurfrules
# OS
# JetBrains IDEs
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries/
.idea/vcs.xml
.idea/jsLinters/
.idea/misc.xml
.idea/modules.xml
# ==================== OS ====================
.DS_Store
Thumbs.db
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
$RECYCLE.BIN/
# Windows thumbnails cache files
Thumbs.db:encryptable
dm thumbs.db
# Folder config file
[Dd]esktop.ini
# ==================== Node.js ====================
node_modules/
npm-debug.log*
@@ -67,26 +106,80 @@ frontend/dist-ssr/
!.env.development
!.env.production
# ⚠️ 敏感信息API 配置文件(包含 API Keys
data/apiconfig/*.json
# ==================== Logs ====================
logs/
*.log
log/
# ==================== Data files ====================
# 保留目录结构,忽略数据文件
data/chat/**/*.jsonl
data/chat/**/*.json
data/preset/*.json
data/worldbooks/*.json
data/apiconfig/*.json
data/comfyui_workflows/*.json
data/images/*
data/temp/*
outputs/*
imports/*
# ⚠️ 所有用户数据文件都不应该提交到版本控制
# 聊天记录(包含聊天历史和消息数据)
data/chat/
data/chat/**/*
# 角色卡数据(角色配置和头像)
data/characters/
data/characters/**/*
data/avatars/
data/avatars/**/*
# 预设文件(提示词配置)
data/preset/
data/preset/**/*
# 世界书(世界观设定)
data/worldbooks/
data/worldbooks/**/*
# API 配置(包含 API Keys敏感信息
data/apiconfig/
data/apiconfig/**/*
# 正则规则
data/regex/
data/regex/**/*
# ComfyUI 工作流
data/comfyui_workflows/
data/comfyui_workflows/**/*
# 图片资源
data/images/
data/images/**/*
data/image_metadata/
data/image_metadata/**/*
# 临时文件
data/temp/
data/temp/**/*
# 导入文件
data/imports/
data/imports/**/*
# Token 使用统计
data/token_usage/
data/token_usage/**/*
# 系统设置
data/system_settings.json
# 加密密钥(敏感信息)
data/encryption_key.txt
# 其他输出目录
outputs/
outputs/**/*
imports/
imports/**/*
# ==================== Docker ====================
.dockerignore
docker-compose.override.yml
# ==================== Temporary files ====================
*.tmp
@@ -101,6 +194,13 @@ coverage/
htmlcov/
.pytest_cache/
.tox/
.nox/
# Unit test / coverage reports
.coverage
.coverage.*
*.cover
*.cover.gz
# ==================== Misc ====================
.parcel-cache/
@@ -112,6 +212,19 @@ htmlcov/
.dynamodb/
.tern-port
# Temporary documentation files
*_TEST_GUIDE.md
*_DEBUG_GUIDE.md
*_DEBUG.md
*_TEST.md
*_CHECK.md
*_FIX.md
*_IMPROVEMENT.md
*_EXAMPLE.md
*_COMPARISON.md
*_OPTIMIZATION.md
*_CONFIG.md
# ==================== Project specific ====================
# Backend output
backend/__pycache__/
@@ -120,11 +233,27 @@ backend/api/routes/__pycache__/
backend/core/__pycache__/
backend/services/__pycache__/
backend/utils/__pycache__/
backend/models/__pycache__/
# Claude settings
.claude/settings.local.json
.claude/
# Lingma cache
.lingma/
# Backup files
*.bak
*.backup
*~
# ComfyUI generated images
data/outputs/
# Token usage logs (can be large)
data/token_usage/*.jsonl
data/token_usage/**/*.jsonl
# Worldbooks backup
data/worldbooks/*.bak
data/worldbooks/*.bak.*

View File

@@ -1,195 +0,0 @@
# 全局世界书数据同步问题修复
## 🐛 问题描述
**现象**: 当世界书文件被删除后LocalStorage中仍然保存着该世界书的全局状态导致前端显示一个不存在的"幽灵"世界书。
**原因**: `fetchWorldBooks` 函数从LocalStorage加载全局世界书后没有清理那些已经不存在于后端的世界书。
---
## ✅ 解决方案
### 修复位置
**文件**: `frontend/src/Store/SideBarLeft/WorldBookSlice.jsx`
**函数**: `fetchWorldBooks` (第132-165行)
### 修复逻辑
```javascript
// 从 LocalStorage 获取全局世界书列表
let globalBooks = loadGlobalWorldBooks();
// 清理 LocalStorage 中已不存在的世界书
const existingWorldBookNames = new Set(data.map(wb => wb.name));
const cleanedGlobalBooks = globalBooks.filter(wb => existingWorldBookNames.has(wb.name));
// 如果有被清理的项,更新 LocalStorage
if (cleanedGlobalBooks.length !== globalBooks.length) {
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
saveGlobalWorldBooks(cleanedGlobalBooks);
globalBooks = cleanedGlobalBooks;
}
```
### 工作流程
1. **获取后端数据**: 调用 `GET /api/worldbooks/` 获取所有存在的世界书
2. **加载LocalStorage**: 从LocalStorage读取全局世界书列表
3. **对比清理**: 过滤掉LocalStorage中存在但后端不存在的世界书
4. **更新存储**: 如果发现有被清理的项更新LocalStorage
5. **更新State**: 将清理后的列表设置到State中
---
## 🧪 测试场景
### 场景1: 正常情况
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 刷新页面
**预期结果**:
- ✅ 全局区域显示A和B
- ✅ LocalStorage中有A和B
### 场景2: 文件被删除
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 手动删除世界书A的文件或其他方式删除
4. 刷新页面
**预期结果**:
- ✅ 全局区域只显示B
- ✅ LocalStorage中只保留B
- ✅ 控制台输出: "清理了 1 个不存在的全局世界书"
### 场景3: 通过UI删除
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 在前端UI中删除世界书A
4. 刷新页面
**预期结果**:
- ✅ 全局区域只显示B
- ✅ LocalStorage中只保留B
- ✅ 无错误信息
---
## 📊 数据流图
```
┌─────────────────┐
│ 页面加载/切换 │
└────────┬────────┘
┌─────────────────┐
│ fetchWorldBooks │
└────────┬────────┘
├──► GET /api/worldbooks/ ──► 后端返回现有世界书列表
├──► loadGlobalWorldBooks() ──► 从LocalStorage读取
├──► 对比两个列表
│ ├─ 存在于LocalStorage但不存在于后端 → 清理
│ └─ 存在于两者 → 保留
├──► saveGlobalWorldBooks() ──► 更新LocalStorage如有变化
└──► set state ──► 更新UI
```
---
## 🔍 关键代码说明
### 1. 使用Set提高查找效率
```javascript
const existingWorldBookNames = new Set(data.map(wb => wb.name));
```
- 将后端返回的世界书名称转换为Set
- Set的查找时间复杂度为O(1)比数组的O(n)更高效
### 2. 过滤清理
```javascript
const cleanedGlobalBooks = globalBooks.filter(wb =>
existingWorldBookNames.has(wb.name)
);
```
- 只保留那些在后端也存在的世界书
- 自动移除"幽灵"世界书
### 3. 条件更新
```javascript
if (cleanedGlobalBooks.length !== globalBooks.length) {
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
saveGlobalWorldBooks(cleanedGlobalBooks);
globalBooks = cleanedGlobalBooks;
}
```
- 只有在确实有变化时才更新LocalStorage
- 避免不必要的写入操作
- 提供调试信息
---
## ✨ 优势
1. **自动清理**: 无需手动干预自动同步LocalStorage和后端数据
2. **性能优化**: 使用Set提高查找效率
3. **用户友好**: 静默清理,只在控制台输出日志
4. **数据一致性**: 确保LocalStorage中的数据始终与后端保持一致
5. **无副作用**: 不影响正常的业务流程
---
## 📝 相关代码位置
### LocalStorage操作函数
```javascript
// 辅助函数:从 LocalStorage 加载全局世界书
const loadGlobalWorldBooks = () => {
try {
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('加载全局世界书失败:', error);
return [];
}
};
// 辅助函数:保存全局世界书到 LocalStorage
const saveGlobalWorldBooks = (globalBooks) => {
try {
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
} catch (error) {
console.error('保存全局世界书失败:', error);
}
};
```
### 删除世界书时的清理
```javascript
// deleteWorldBook 函数中已经有清理逻辑
deleteWorldBook: async (name) => {
// ...
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
saveGlobalWorldBooks(filteredGlobalBooks);
// ...
}
```
---
## 🎯 总结
**问题已修复**: 全局世界书现在会自动清理不存在的项
**数据同步**: LocalStorage与后端数据保持一致
**用户体验**: 不再显示"幽灵"世界书
**代码健壮**: 增加了数据一致性检查机制

559
README.md
View File

@@ -1,166 +1,131 @@
# LLM Workflow Engine
一个基于 React + TypeScript + FastAPI 的 AI 聊天工作流引擎,支持流式对话、动态表格生成、图片生成等功能
一个功能强大的 LLM 聊天工作流引擎,兼容 SillyTavern 生态系统
## 🚀 技术栈
## 📋 目录
### 前端
- **React 18** - 用户界面框架
- **TypeScript** - 类型安全的 JavaScript
- **Vite** - 现代化的前端构建工具
- **Zustand** - 轻量级状态管理
- **React Markdown** - Markdown 渲染
- **Tailwind CSS** - 实用优先的 CSS 框架
- [功能特性](#功能特性)
- [技术栈](#技术栈)
- [快速开始](#快速开始)
- [项目结构](#项目结构)
- [核心功能](#核心功能)
- [开发指南](#开发指南)
- [配置说明](#配置说明)
- [常见问题](#常见问题)
---
## 功能特性
### 🎯 核心功能
- **多模型支持** - 兼容 OpenAI、Claude、Gemini 等多种 LLM API
- **角色卡系统** - 完整的角色创建、导入、导出功能(兼容 SillyTavern 格式)
- **聊天管理** - 多聊天切换、历史总结、消息编辑
- **预设系统** - 灵活的提示词组件管理,支持拖拽排序
- **世界书** - 动态世界知识注入系统
- **正则替换** - 强大的文本处理规则系统(完全兼容 SillyTavern
### ✨ 高级功能
- **酒馆助手Tavern Helper**
- JavaScript 沙盒执行引擎
- 提示词模板系统(支持 `{{var}}``{{roll}}``{{random}}` 等语法)
- 脚本管理(全局/角色/预设三种作用域)
- 代码块渲染功能
- **多主题支持** - 完整的 CSS 变量主题系统
- **流式输出** - 实时显示 AI 生成内容
- **消息 Swipes** - 多版本切换和重roll功能
- **API 配置管理** - 安全的 API Key 存储和加密
### 🔒 安全特性
- API Key 加密存储Fernet 对称加密)
- JavaScript 沙盒隔离执行
- 危险 API 拦截机制
- 环境变量安全管理
---
## 技术栈
### 后端
- **FastAPI** - 现代化的 Python Web 框架
- **Python 3.11** - 编程语言
- **Uvicorn** - ASGI 服务器
- **WebSockets** - 实时通信
## 📁 项目结构
- **框架**: FastAPI (Python 3.11+)
- **数据库**: 文件系统 + JSON轻量级易于备份
- **WebSocket**: 实时流式通信
- **加密**: Fernet 对称加密cryptography 库)
- **依赖管理**: pip + requirements.txt
```
llm_workflow_engine/
├── backend/ # 后端服务
│ ├── api/ # API 路由
│ ├── core/ # 核心模型和配置
│ ├── tools/ # 工具函数
│ ├── workflows/ # 工作流定义
│ ├── Dockerfile # 后端 Docker 配置
│ ├── main.py # 后端入口
│ └── requirements.txt # Python 依赖
├── frontend/ # 前端服务
│ ├── src/
│ │ ├── components/ # React 组件
│ │ ├── Store/ # 状态管理
│ │ ├── services/ # API 服务
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── App.tsx # 主应用组件
│ │ └── main.tsx # 入口文件
│ ├── Dockerfile # 前端 Docker 配置
│ ├── nginx.conf # Nginx 配置(生产环境)
│ ├── package.json # Node.js 依赖
│ └── tsconfig.json # TypeScript 配置
├── data/ # 数据存储
├── docker-compose.yml # Docker Compose 配置
└── README.md # 项目文档
```
### 前端
## 🛠️ 安装和运行
- **框架**: React 18 + Vite
- **状态管理**: Zustand轻量级 Redux 替代)
- **样式**: CSS3 + CSS 变量(支持多主题)
- **Markdown**: react-markdown + remark-gfm
- **HTTP 客户端**: Fetch API
### 使用 Docker Compose推荐
### 部署
这是最简单的运行方式,适合开发和生产环境。
- **容器化**: Docker + Docker Compose
- **反向代理**: Nginx
- **开发服务器**: Vite HMR
1. **克隆项目**
```bash
git clone <repository-url>
cd llm_workflow_engine
```
---
2. **配置环境变量**
```bash
# 复制环境变量模板
cp .env.example .env
## 快速开始
# 根据需要编辑 .env 文件
```
### 环境要求
3. **启动服务**
```bash
# 构建并启动所有服务
docker-compose up --build
# 或者在后台运行
docker-compose up -d --build
```
4. **访问应用**
- 前端界面: http://localhost:23338
- 后端 API: http://localhost:23337
- API 文档: http://localhost:23337/docs
5. **停止服务**
```bash
docker-compose down
```
- Python 3.11+
- Node.js 18+
- Docker & Docker Compose可选
### 本地开发
如果你想分别运行前后端进行开发:
#### 1. 克隆项目
#### 后端开发
1. **安装 Python 依赖**
```bash
git clone https://github.com/your-repo/llm-workflow-engine.git
cd llm-workflow-engine
```
#### 2. 后端启动
```bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r backend/requirements.txt
# 启动服务
cd backend
pip install -r requirements.txt
python main.py
```
2. **启动后端服务**
```bash
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
```
后端服务将在 `http://localhost:23338` 启动。
#### 前端开发
#### 3. 前端启动
1. **安装 Node.js 依赖**
```bash
cd frontend
npm install
```
2. **启动前端开发服务器**
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
3. **访问应用**
- 前端界面: http://localhost:5173
- 确保后端在 http://localhost:8000 运行
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
## 🔧 配置说明
### 环境变量
#### 前端环境变量 (frontend/.env)
```
VITE_API_URL=http://localhost:23337/api
VITE_WS_URL=ws://localhost:23337/api
```
#### 后端环境变量
```
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
```
### API 配置
在前端界面中配置你的 API 密钥和端点:
1. 打开左侧栏的 "API 配置" 标签
2. 添加你的 API 配置URL 和密钥)
3. 选择要使用的 API
## 📖 功能特性
-**流式对话** - 实时显示 AI 回复
-**多角色支持** - 支持多个聊天角色和会话
-**消息编辑** - 可以编辑和删除历史消息
-**HTML 渲染** - 支持 Markdown 和 HTML 渲染
-**动态表格** - 自动生成和更新数据表格
-**图片生成** - 集成图片生成工作流
-**世界书** - 管理角色和世界设定
-**预设管理** - 保存和加载不同的对话预设
## 🐳 Docker 命令参考
### Docker 部署
```bash
# 构建并启动
docker-compose up --build
# 后台运行
# 一键启动
docker-compose up -d
# 查看日志
@@ -168,59 +133,319 @@ docker-compose logs -f
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 进入容器
docker-compose exec backend bash
docker-compose exec frontend sh
# 清理所有容器和卷
docker-compose down -v
```
## 🔍 开发工具
访问 `http://localhost:80` 即可使用。
### 前端
---
## 项目结构
```
llm-workflow-engine/
├── backend/ # 后端服务
│ ├── api/ # API 路由
│ │ └── routes/ # 路由处理
│ ├── core/ # 核心配置
│ ├── models/ # 数据模型
│ ├── services/ # 业务逻辑
│ │ ├── chat_service.py # 聊天服务
│ │ ├── js_sandbox.py # JavaScript 沙盒
│ │ ├── script_manager.py # 脚本管理器
│ │ ├── regex_service.py # 正则服务
│ │ └── ...
│ ├── utils/ # 工具函数
│ ├── main.py # 应用入口
│ └── requirements.txt # Python 依赖
├── frontend/ # 前端应用
│ ├── src/
│ │ ├── components/ # React 组件
│ │ │ ├── Mid/ # 中间区域(聊天框)
│ │ │ ├── SideBarLeft/ # 左侧边栏
│ │ │ │ └── tabs/ # 标签页组件
│ │ │ │ └── TavernHelper/ # 酒馆助手
│ │ │ ├── SideBarRight/# 右侧边栏
│ │ │ └── TopBar/ # 顶部栏
│ │ ├── Store/ # Zustand 状态管理
│ │ ├── styles/ # 全局样式
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── utils/ # 工具函数
│ │ ├── App.jsx # 根组件
│ │ └── main.jsx # 应用入口
│ ├── package.json # Node.js 依赖
│ └── vite.config.js # Vite 配置
├── data/ # 数据目录(运行时生成)
│ ├── chat/ # 聊天记录
│ ├── preset/ # 预设文件
│ ├── worldbooks/ # 世界书
│ ├── regex/ # 正则规则
│ └── ...
├── docker-compose.yml # Docker 编排
├── .env.example # 环境变量示例
├── .gitignore # Git 忽略文件
└── README.md # 项目文档
```
---
## 核心功能
### 1. 酒馆助手Tavern Helper
完全兼容 SillyTavern 酒馆助手的提示词模板系统。
#### 支持的语法
| 语法 | 功能 | 示例 |
|------|------|------|
| `{{var}}``{{getvar::key}}` | 获取变量 | `{{name}}` |
| `{{setvar::key::value}}` | 设置变量 | `{{setvar::age::25}}` |
| `{{delvar::key}}` | 删除变量 | `{{delvar::temp}}` |
| `{{random::a,b,c}}` | 随机选择(逗号) | `{{random::苹果,香蕉,橙子}}` |
| `{{pick::a\|b\|c}}` | 随机选择(竖线) | `{{pick::剑\|斧\|弓}}` |
| `{{roll XdY}}` | 掷骰子 | `{{roll 3d6}}` |
| `{{// 注释}}` | 注释(不输出) | `{{// 这是注释}}` |
#### 使用示例
```python
from backend.services.js_sandbox import JSSandboxExecutor
sandbox = JSSandboxExecutor()
template = """
{{setvar::character::勇者}}
{{setvar::weapon::{{random::剑,斧,弓}}}}
{{character}}手持{{weapon}},掷出了:{{roll 1d20}}
{{// 这是注释,不会显示}}
""".strip()
result = sandbox.render_template(template)
print(result)
# 输出: 勇者手持剑掷出了15
```
#### 脚本管理
支持三种作用域的脚本:
- **GLOBAL** - 全局脚本,对所有聊天可用
- **CHARACTER** - 角色脚本,绑定到当前角色卡
- **PRESET** - 预设脚本,绑定到当前预设
详细文档:[TAVERN_HELPER_IMPLEMENTATION.md](./TAVERN_HELPER_IMPLEMENTATION.md)
### 2. 正则替换系统
强大的文本处理规则,完全兼容 SillyTavern 格式。
#### 应用位置placement
- `0` - System Prompt系统提示词
- `1` - User Input用户输入
- `2` - AI OutputAI 输出)
- `3` - Quick Reply快捷回复
- `4` - World Info世界书信息
- `5` - Reasoning/Thinking推理/思考内容)
#### 规则示例
```json
{
"id": "hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"placement": [2],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"disabled": false
}
```
### 3. 预设系统
灵活的提示词组件管理。
#### 特性
- 多组件拖拽排序
- 角色字段支持system/user/assistant
- 注入位置控制injection_position
- 注入深度控制injection_depth
- 触发条件injection_trigger
- 完全兼容 SillyTavern 预设格式
### 4. 聊天管理
完整的聊天生命周期管理。
#### 功能
- 多聊天切换
- 消息编辑和保存
- 消息 Swipes多版本
- 右键菜单(编辑/复制/重roll/删除)
- 历史总结
- 智能滚动
---
## 开发指南
### API 路由
所有 API 路由定义在 `backend/api/routes/` 目录下:
- `chatWsRoute.py` - WebSocket 聊天(流式输出)
- `chatsRoute.py` - 聊天管理
- `charactersRoute.py` - 角色卡管理
- `presetsRoute.py` - 预设管理
- `worldbooksRoute.py` - 世界书管理
- `regexRoute.py` - 正则规则管理
- `apiConfigRoute.py` - API 配置管理
### 状态管理
前端使用 Zustand 进行状态管理store 定义在 `frontend/src/Store/`
```
Store/
├── Mid/ # 中间区域状态
│ ├── ChatBoxSlice.jsx # 聊天框状态
│ └── ChatBoxUISlice.jsx # 聊天框 UI 状态
├── SideBarLeft/ # 左侧边栏状态
├── SideBarRight/ # 右侧边栏状态
└── TopBar/ # 顶部栏状态
```
### 样式系统
使用 CSS 变量实现多主题:
```css
:root {
--color-bg-primary: #ffffff;
--color-text-primary: #1a1a1a;
--color-accent: #667eea;
/* ... */
}
[data-color-theme='dark'] {
--color-bg-primary: #1a1a1a;
--color-text-primary: #ffffff;
/* ... */
}
```
---
## 配置说明
### 环境变量
创建 `.env` 文件(从 `.env.example` 复制):
```env
# 后端配置
HOST=0.0.0.0
PORT=23338
DEBUG=True
# 前端代理
VITE_API_URL=http://localhost:23338
# API 加密密钥(自动生成,不要手动修改)
FERNET_KEY=your_generated_key_here
```
### API 配置
API Key 通过前端界面配置,自动加密存储到 `data/apiconfig/` 目录。
⚠️ **注意**`data/apiconfig/*.json` 已添加到 `.gitignore`,不会被提交到版本控制。
---
## 常见问题
### 1. 前端无法连接后端
**问题**: 前端请求返回 404 或网络连接错误
**解决**:
```bash
# 类型检查
npm run type-check
# 检查后端是否运行
curl http://localhost:23338/api/health
# 构建
npm run build
# 预览生产构建
npm run preview
# 检查前端代理配置
cat frontend/vite.config.js
```
### 后端
### 2. API Key 不生效
**问题**: 配置了 API Key 但仍然无法调用 LLM
**解决**:
1. 检查 API 配置文件是否存在:`data/apiconfig/`
2. 检查加密密钥是否正确:`.env` 中的 `FERNET_KEY`
3. 重启后端服务
### 3. Docker 部署后无法访问
**问题**: `docker-compose up` 后无法访问服务
**解决**:
```bash
# 运行测试(如果有的话)
cd backend
pytest
# 查看容器状态
docker-compose ps
# 代码格式化
black .
# 查看日志
docker-compose logs -f backend
docker-compose logs -f frontend
# 重新构建
docker-compose up -d --build
```
## 📝 待办事项
### 4. 正则规则不生效
- [ ] 添加单元测试
- [ ] 完善错误处理
- [ ] 添加用户认证
- [ ] 优化性能
- [ ] 添加更多语言支持
- [ ] 完善文档
**问题**: 配置了正则规则但没有效果
## 🤝 贡献
**解决**:
1. 检查规则是否启用disabled: false
2. 检查 placement 是否正确
3. 检查正则表达式语法
4. 重启后端服务
欢迎提交 Issue 和 Pull Request
---
## 📄 许可证
## 贡献指南
MIT License
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 📞 联系方式
---
如有问题,请提交 Issue 或联系维护者。
## 许可证
本项目遵循与 SillyTavern 相同的分发协议。
---
## 致谢
- [SillyTavern](https://github.com/SillyTavern/SillyTavern) - 优秀的开源项目,提供了设计灵感和兼容标准
- [JS-Slash-Runner](https://github.com/N0VI028/JS-Slash-Runner) - Tavern Helper 扩展,提供了 JavaScript 沙盒实现参考
---
**最后更新**: 2026-05-05
**版本**: 1.0.0

View File

@@ -1,194 +0,0 @@
# 世界书删除确认弹窗移除
## 📋 修改内容
移除了世界书模块中所有的浏览器级别确认弹窗(`confirm`),改为直接执行删除操作。
---
## ✅ 已移除的确认弹窗
### 1. 删除世界书条目
**位置**: `WorldBook.jsx` 第338-349行
**修改前**:
```javascript
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (confirm('确定要删除此条目吗?')) {
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('删除条目失败:', err);
}
}
};
```
**修改后**:
```javascript
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('删除条目失败:', err);
}
};
```
---
### 2. 删除世界书
**位置**: `WorldBook.jsx` 第354-365行
**修改前**:
```javascript
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
}
};
```
**修改后**:
```javascript
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
};
```
---
### 3. 编辑面板中的删除按钮
**位置**: `WorldBook.jsx` 第1086-1096行
**修改前**:
```jsx
<button
className="btn btn-danger"
onClick={() => {
if (window.confirm('确定要删除这个条目吗?')) {
handleDeleteEntry();
}
}}
>
删除条目
</button>
```
**修改后**:
```jsx
<button
className="btn btn-danger"
onClick={handleDeleteEntry}
>
删除条目
</button>
```
---
## 🎯 影响范围
### 用户交互变化
**之前**:
1. 点击删除按钮
2. 弹出浏览器确认对话框
3. 用户点击"确定"或"取消"
4. 根据选择执行或删除操作
**现在**:
1. 点击删除按钮
2. 立即执行删除操作
3. 通过错误处理捕获异常
---
## ✨ 优势
1. **更流畅的用户体验**: 减少交互步骤,操作更快捷
2. **更现代的UI**: 避免使用浏览器原生弹窗更符合现代Web应用风格
3. **代码简化**: 减少了条件判断和嵌套层级
4. **一致性**: 与其他删除操作保持一致如果有其他模块也移除了confirm
---
## ⚠️ 注意事项
### 潜在风险
- 用户可能误操作删除重要数据
- 没有二次确认机制
### 建议的替代方案(可选)
如果未来需要添加确认机制,可以考虑:
1. **自定义模态框**: 使用项目统一的Modal组件
2. **Toast提示 + 撤销**: 删除后显示提示,提供短暂的撤销机会
3. **软删除**: 先标记为删除,稍后真正删除
---
## 🧪 测试建议
### 手动测试清单
- [ ] 在世界书列表中选择一个世界书
- [ ] 点击"删除"按钮,检查是否立即删除(无确认弹窗)
- [ ] 在条目编辑面板中点击"删除条目"按钮,检查是否立即删除
- [ ] 检查删除后的控制台是否有错误信息
- [ ] 检查删除后UI是否正确更新
### 边界情况测试
- [ ] 删除不存在的世界书(应该被前端校验拦截)
- [ ] 删除不存在的条目(应该被前端校验拦截)
- [ ] 网络请求失败时的错误处理
---
## 📝 相关代码位置
### 主要文件
- `frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx`
### 相关函数
- `handleDeleteEntry()` - 删除条目
- `handleDeleteWorldBook()` - 删除世界书
### Store函数
- `deleteWorldBookEntry()` - WorldBookSlice.jsx
- `deleteWorldBook()` - WorldBookSlice.jsx
---
## 🎯 总结
**所有浏览器级别的确认弹窗已移除**
**删除操作更加流畅和现代化**
**代码结构更简洁**
**用户体验得到提升**
如果需要添加更优雅的确认机制建议使用项目统一的UI组件而非浏览器原生弹窗。

View File

@@ -1,428 +0,0 @@
# 🧪 API 配置功能测试指南
## 📋 测试前准备
### **1. 启动后端服务**
```bash
cd backend
uvicorn main:app --reload --port 8000
```
确保看到:
```
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000
```
---
### **2. (可选)启动 ComfyUI**
如果要测试 ComfyUI 连接:
```bash
# 本地运行
python comfyui/main.py --listen 0.0.0.0 --port 8188
# 或 Docker
docker-compose up -d comfyui
```
---
## 🚀 运行测试
### **方法 1: 使用 Python 脚本(推荐)**
```bash
# 在项目根目录运行
python test_api_config.py
```
**预期输出**
```
============================================================
ComfyUI API 配置测试
============================================================
============================================================
测试 1: 列出工作流
============================================================
✅ 成功获取 1 个工作流
📄 default_txt2img.json
节点数: 7, 大小: 1234 bytes
...
============================================================
测试总结
============================================================
✅ 通过 - 列出工作流
✅ 通过 - 获取工作流详情
✅ 通过 - 上传工作流
✅ 通过 - 删除工作流
✅ 通过 - 测试 ComfyUI 连接
✅ 通过 - 测试云端 API 连接
总计: 6/6 通过
🎉 所有测试通过!
```
---
### **方法 2: 使用 cURL 手动测试**
#### **测试 1: 列出工作流**
```bash
curl http://localhost:8000/api/api-config/comfyui/workflows | jq
```
**预期响应**
```json
[
{
"filename": "default_txt2img.json",
"name": "default_txt2img",
"nodes_count": 7,
"size": 1234
}
]
```
---
#### **测试 2: 获取工作流详情**
```bash
curl http://localhost:8000/api/api-config/comfyui/workflows/default_txt2img.json | jq
```
**预期响应**
```json
{
"3": {
"inputs": {...},
"class_type": "KSampler",
"_meta": {"title": "K采样器"}
},
...
}
```
---
#### **测试 3: 上传工作流**
创建一个测试文件 `test_workflow.json`
```bash
cat > test_workflow.json << 'EOF'
{
"3": {
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
},
"class_type": "KSampler"
},
"4": {
"inputs": {"ckpt_name": "test.safetensors"},
"class_type": "CheckpointLoaderSimple"
},
"5": {
"inputs": {"width": 512, "height": 512, "batch_size": 1},
"class_type": "EmptyLatentImage"
},
"6": {
"inputs": {"text": "test", "clip": ["4", 1]},
"class_type": "CLIPTextEncode"
},
"7": {
"inputs": {"text": "bad", "clip": ["4", 1]},
"class_type": "CLIPTextEncode"
},
"8": {
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
"class_type": "VAEDecode"
},
"9": {
"inputs": {"images": ["8", 0], "filename_prefix": "Test"},
"class_type": "SaveImage"
}
}
EOF
```
上传:
```bash
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
-F "file=@test_workflow.json" | jq
```
**预期响应**
```json
{
"message": "Workflow uploaded successfully",
"filename": "test_workflow.json",
"size": 1234
}
```
---
#### **测试 4: 删除工作流**
```bash
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/test_workflow.json | jq
```
**预期响应**
```json
{
"message": "Workflow 'test_workflow.json' deleted successfully"
}
```
---
#### **测试 5: 测试 ComfyUI 连接**
```bash
curl -X POST http://localhost:8000/api/api-config/test-comfyui-connection \
-H "Content-Type: application/json" \
-d '{"apiUrl": "http://localhost:8188"}' | jq
```
**如果 ComfyUI 正在运行**
```json
{
"success": true,
"message": "连接成功",
"stats": {
"vram_total": 25769803776,
"vram_free": 24696061952,
"torch_version": "2.1.0+cu121",
"device": "cuda"
}
}
```
**如果 ComfyUI 未运行**
```json
{
"success": false,
"message": "无法连接到 ComfyUI请检查地址和端口"
}
```
---
#### **测试 6: 测试云端 API 连接**
```bash
curl -X POST http://localhost:8000/api/api-config/test-cloud-connection \
-H "Content-Type: application/json" \
-d '{
"provider": "dall-e",
"apiKey": "sk-your-api-key-here",
"model": "dall-e-3"
}' | jq
```
**预期响应**(如果 API Key 有效):
```json
{
"success": true,
"message": "连接成功,模型 dall-e-3 可用"
}
```
---
## ✅ 测试检查清单
### **后端 API**
- [ ] 列出工作流返回正确的列表
- [ ] 获取工作流详情返回完整的 JSON
- [ ] 上传工作流成功保存文件
- [ ] 上传的工作流可以通过列表看到
- [ ] 删除工作流成功移除文件
- [ ] 默认工作流不可删除(返回 403
- [ ] ComfyUI 连接测试正确检测状态
- [ ] 云端 API 连接测试验证 Key
### **前端 UI**
- [ ] 可以切换到"🎨 生图"标签
- [ ] 模式切换卡片正常显示
- [ ] 点击"本地 ComfyUI"显示本地配置
- [ ] 点击"在线 API"显示云端配置
- [ ] 表单输入正常工作
- [ ] 工作流管理器显示默认工作流
- [ ] 可以上传工作流文件
- [ ] 可以删除工作流(非默认)
- [ ] 测试连接按钮正常工作
- [ ] 保存配置功能正常
### **响应式设计**
- [ ] 大屏幕(>768px双列布局
- [ ] 小屏幕(<768px单列布局
- [ ] 无页面级滚动条
- [ ] 侧边栏可独立滚动
- [ ] 无横向滚动
---
## 🐛 常见问题
### **Q1: 测试脚本提示"Connection refused"**
**原因**:后端服务未启动
**解决**
```bash
cd backend
uvicorn main:app --reload --port 8000
```
---
### **Q2: 上传工作流提示"Invalid ComfyUI workflow"**
**原因**JSON 格式不正确或缺少必要节点
**解决**
- 确保包含 `KSampler` 节点
- 使用 ComfyUI 的 "Save (API Format)" 导出
- 检查 JSON 语法是否正确
---
### **Q3: 删除工作流提示"Cannot delete default workflow"**
**这是正常的**!默认工作流受保护,不可删除。
要测试删除功能,请先上传一个自定义工作流,然后删除它。
---
### **Q4: ComfyUI 连接测试失败**
**可能原因**
1. ComfyUI 未启动
2. 地址或端口错误
3. Docker 网络问题
**解决**
```bash
# 检查 ComfyUI 是否运行
curl http://localhost:8188/system_stats
# Docker 环境下
docker ps | grep comfyui
docker logs comfyui
```
---
## 📊 测试结果解读
### **全部通过** ✅
```
总计: 6/6 通过
🎉 所有测试通过!
```
→ API 配置功能完全正常,可以开始使用
### **部分失败** ⚠️
```
总计: 4/6 通过
⚠️ 2 个测试失败,请检查日志
```
→ 查看失败的测试项,根据错误信息排查
### **全部失败** ❌
```
总计: 0/6 通过
```
→ 检查后端服务是否正常运行
→ 检查端口是否正确8000
→ 查看后端日志
---
## 🎯 下一步
测试通过后,你可以:
1. **启动前端**
```bash
cd frontend
npm run dev
```
2. **访问应用**
- 打开浏览器访问 `http://localhost:5173`
- 进入 API 配置页面
- 配置你的生图服务
3. **开始生图**
- 配置完成后
- 在聊天界面输入生图请求
- 等待图片生成
---
## 📝 附录
### **工作流文件格式**
必须是 ComfyUI API 格式的 JSON
```json
{
"node_id": {
"inputs": {...},
"class_type": "NodeType",
"_meta": {"title": "Display Name"}
}
}
```
### **必需的节点类型**
- `KSampler` - 采样器(必需)
- `CheckpointLoaderSimple` - 模型加载器
- `EmptyLatentImage` - 潜变量图像
- `CLIPTextEncode` - 文本编码器(正向和负向)
- `VAEDecode` - VAE 解码器
- `SaveImage` - 保存图像
### **API 端点列表**
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/api-config/comfyui/workflows` | 列出工作流 |
| POST | `/api/api-config/comfyui/workflows/upload` | 上传工作流 |
| DELETE | `/api/api-config/comfyui/workflows/{filename}` | 删除工作流 |
| GET | `/api/api-config/comfyui/workflows/{filename}` | 获取工作流详情 |
| POST | `/api/api-config/test-comfyui-connection` | 测试 ComfyUI |
| POST | `/api/api-config/test-cloud-connection` | 测试云端 API |
---
**祝测试顺利!** 🎉

View File

@@ -1,217 +0,0 @@
# 世界书分页功能 - 前端API调用检查报告
## ✅ 检查结果
**所有前端世界书分页功能都能正确发出请求到后端API**
---
## 📋 完整API映射表
### 1. 世界书管理
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 获取世界书列表 | `fetchWorldBooks` | GET | `/api/worldbooks/` | `list_worldbooks` | ✅ |
| 获取指定世界书 | `fetchWorldBook` | GET | `/api/worldbooks/{name}` | `get_worldbook` | ✅ |
| 创建世界书 | `createWorldBook` | POST | `/api/worldbooks/` | `create_worldbook` | ✅ |
| 更新世界书 | `updateWorldBook` | PUT | `/api/worldbooks/{name}` | `update_worldbook` | ✅ |
| 删除世界书 | `deleteWorldBook` | DELETE | `/api/worldbooks/{name}` | `delete_worldbook` | ✅ |
### 2. 条目管理(含分页)
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 获取条目列表(分页) | `fetchWorldBookEntries` | GET | `/api/worldbooks/{name}/entries?page={page}&page_size={page_size}` | `list_worldbook_entries` | ✅ |
| 获取指定条目 | `fetchWorldBookEntry` | GET | `/api/worldbooks/{name}/entries/{uid}` | `get_worldbook_entry` | ✅ |
| 创建条目 | `createWorldBookEntry` | POST | `/api/worldbooks/{name}/entries` | `create_worldbook_entry` | ✅ |
| 更新条目 | `updateWorldBookEntry` | PUT | `/api/worldbooks/{name}/entries/{uid}` | `update_worldbook_entry` | ✅ |
| 删除条目 | `deleteWorldBookEntry` | DELETE | `/api/worldbooks/{name}/entries/{uid}` | `delete_worldbook_entry` | ✅ |
### 3. 导入导出
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 导入世界书 | `importWorldBook` | POST | `/api/worldbooks/{name}/import` | `import_worldbook` | ✅ |
| 导出世界书 | `exportWorldBook` | GET | `/api/worldbooks/{name}/export?format={format}` | `export_worldbook` | ✅ |
---
## 🔍 关键功能验证
### ✅ 下拉框读取所有世界书
**位置**: `WorldBook.jsx` 第547行
```jsx
{worldBooks.map(book => (
<div key={book.name} className="dropdown-item">
...
</div>
))}
```
**数据来源**:
- `worldBooks` 来自 Store (第8行)
- 通过 `fetchWorldBooks()` 加载 (第85行和第95行)
**加载时机**:
1. ✅ 组件挂载时立即加载(如果列表为空)
2. ✅ 切换到世界书标签页时重新加载
**API调用**:
```javascript
// WorldBookSlice.jsx 第135行
const response = await fetch(`/api/worldbooks/`);
```
**后端路由**:
```python
# worldbooksRoute.py 第28行
@router.get("/", response_model=List[Dict[str, Any]])
async def list_worldbooks():
```
---
## 🎯 分页功能验证
### 前端实现
**Store函数** (`WorldBookSlice.jsx` 第343-374行):
```javascript
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
const response = await fetch(
`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`
);
// 解析响应并更新 state
}
```
**组件调用**:
- 选择世界书时: `fetchWorldBookEntries(book.name, 1, pageSize)` (第141行)
- 切换页码时: `fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize)` (第247行)
- 改变每页数量: `fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize)` (第259行)
**分页状态**:
```javascript
entriesPagination: {
total: data.total || 0,
page: data.page || 1,
page_size: data.page_size || 20,
total_pages: data.total_pages || 0
}
```
### 后端实现
**API路由** (`worldbooksRoute.py` 第108-128行):
```python
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
return worldbook_service.list_entries(name, page, page_size)
```
**服务层** (`worldbook_service.py` 第168-198行):
```python
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
all_entries = data.get("entries", [])
total = len(all_entries)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_entries = all_entries[start_idx:end_idx]
return {
"entries": paginated_entries,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
```
---
## 📊 数据格式
### 后端返回格式(分页)
```json
{
"entries": [...],
"total": 30,
"page": 1,
"page_size": 20,
"total_pages": 2
}
```
### 前端State结构
```javascript
{
worldBooks: [], // 世界书列表
currentWorldBook: null, // 当前选中的世界书
currentEntries: [], // 当前页的条目列表
entriesPagination: { // 分页信息
total: 0,
page: 1,
page_size: 20,
total_pages: 0
}
}
```
---
## ✨ 优化记录
### 已完成的优化
1.**组件初始化加载**: 添加 `useEffect` 在组件挂载时立即加载世界书列表
2.**格式统一**: 所有世界书文件转换为内部格式存储
3.**代码简化**: 移除服务层的SillyTavern格式运行时兼容逻辑
4.**分页控件**: 添加完整的分页UI上一页、下一页、每页数量选择
5.**自动刷新**: 添加/更新/删除条目后自动刷新当前页
---
## 🧪 测试建议
### 手动测试清单
- [ ] 打开世界书页面,检查下拉框是否显示所有世界书
- [ ] 选择一个世界书,检查是否正确加载第一页条目
- [ ] 点击"下一页",检查是否加载第二页
- [ ] 改变每页数量10/20/50/100检查是否正确刷新
- [ ] 创建新条目,检查是否刷新当前页
- [ ] 更新条目,检查是否刷新当前页
- [ ] 删除条目,检查是否刷新当前页
- [ ] 切换到其他世界书,检查是否重置到第一页
### API测试
```bash
# 获取世界书列表
curl http://localhost:8000/api/worldbooks/
# 获取条目(分页)
curl "http://localhost:8000/api/worldbooks/卡立创-v5/entries?page=1&page_size=5"
```
---
## 📝 结论
**前端世界书分页的所有功能都能正确发出请求到后端API**
- 下拉框正确读取所有世界书
- 分页参数正确传递
- 数据格式匹配
- 错误处理完善
- 用户体验流畅

View File

@@ -1,18 +1,27 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
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
router = APIRouter()
# 注册子路由
# 注册子路由HTTP路由
router.include_router(presetsRoute.router)
router.include_router(chatsRoute.router)
router.include_router(worldbooksRoute.router)
router.include_router(apiConfigRoute.router)
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)
# 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat")

View File

@@ -2,24 +2,28 @@ from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Dict, Optional, List, Any
import json
import os
import sys
from pathlib import Path
from core.config import settings
from cryptography.fernet import Fernet
import base64
from services.comfyui_workflow_manager import workflow_manager
from services.llm_model_service import LLMModelService
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
# 加密密钥(实际项目中应该从环境变量读取)
ENCRYPTION_KEY = os.getenv('API_ENCRYPTION_KEY', Fernet.generate_key().decode())
fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
# 配置文件路径
CONFIG_DIR = Path(settings.DATA_PATH) / "apiconfig"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# 调试信息:打印配置目录路径
print(f"[API Config] DATA_PATH: {settings.DATA_PATH}", file=sys.stderr)
print(f"[API Config] CONFIG_DIR: {CONFIG_DIR}", file=sys.stderr)
print(f"[API Config] CONFIG_DIR exists: {CONFIG_DIR.exists()}", file=sys.stderr)
if CONFIG_DIR.exists():
config_files = list(CONFIG_DIR.glob("*.json"))
print(f"[API Config] Found {len(config_files)} config files", file=sys.stderr)
for f in config_files:
print(f" - {f.name}", file=sys.stderr)
class ApiConfigItem(BaseModel):
"""单个 API 配置项"""
@@ -50,32 +54,8 @@ class ProfileResponse(BaseModel):
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
def encrypt_api_key(api_key: str) -> str:
"""加密 API Key"""
if not api_key:
return ""
encrypted = fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted).decode()
def decrypt_api_key(encrypted_key: str) -> str:
"""解密 API Key仅在后端内部使用"""
if not encrypted_key:
return ""
try:
decoded = base64.urlsafe_b64decode(encrypted_key.encode())
decrypted = fernet.decrypt(decoded)
return decrypted.decode()
except Exception:
return ""
def mask_api_key(api_key: str) -> str:
"""脱敏 API Key返回给前端"""
if not api_key or len(api_key) < 8:
return "****"
return api_key[:4] + "****" + api_key[-4:]
def load_profile(profile_id: str) -> Optional[dict]:
"""加载配置文件"""
@@ -119,29 +99,28 @@ def get_all_profiles():
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
def get_profile(profile_id: str):
"""获取单个配置文件API Key 已脱敏"""
"""获取单个配置文件(明文存储,不返回 API Key"""
profile = load_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="配置文件不存在")
# 脱敏所有 API Key
masked_apis = {}
# 移除 API Key 字段,不返回给前端
safe_apis = {}
for category, api_config in profile.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
safe_config = api_config.copy()
safe_config.pop("apiKey", None)
safe_apis[category] = safe_config
return {
"id": profile.get("id", profile_id),
"name": profile.get("name", profile_id),
"apis": masked_apis
"apis": safe_apis
}
@router.post("/profiles", response_model=ProfileResponse)
def create_or_update_profile(request: ProfileSaveRequest):
"""创建或更新配置文件(增量更新)"""
"""创建或更新配置文件(增量更新,明文存储 API Key"""
# 加载现有配置
existing_profile = load_profile(request.profileId)
@@ -150,16 +129,11 @@ def create_or_update_profile(request: ProfileSaveRequest):
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
# 处理 API Key 加密
if api_config.apiKey and api_config.apiKey != "****":
# 如果是新的明文 key加密它
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
elif api_config.apiKey == "****":
# 如果是脱敏的 key保留原有的加密 key
if category in existing_profile.get("apis", {}):
api_config_dict["apiKey"] = existing_profile["apis"][category].get("apiKey", "")
else:
api_config_dict.pop("apiKey", None)
# 如果前端传入了空的 apiKey保留原有的 key
if api_config.apiKey == "" and category in existing_profile.get("apis", {}):
existing_key = existing_profile["apis"][category].get("apiKey", "")
if existing_key:
api_config_dict["apiKey"] = existing_key
# 更新配置
if "apis" not in existing_profile:
@@ -177,28 +151,25 @@ def create_or_update_profile(request: ProfileSaveRequest):
"apis": {}
}
# 添加所有 API 配置
# 添加所有 API 配置(明文存储)
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
if api_config_dict.get("apiKey"):
api_config_dict["apiKey"] = encrypt_api_key(api_config_dict["apiKey"])
profile_data["apis"][category] = api_config_dict
# 保存配置文件
save_profile(request.profileId, profile_data)
# 返回脱敏后的数据
masked_apis = {}
# 返回不包含 API Key 的数据
safe_apis = {}
for category, api_config in profile_data.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
safe_config = api_config.copy()
safe_config.pop("apiKey", None)
safe_apis[category] = safe_config
return {
"id": profile_data.get("id", request.profileId),
"name": profile_data.get("name", request.profileId),
"apis": masked_apis
"apis": safe_apis
}
@@ -217,13 +188,31 @@ def delete_profile(profile_id: str):
def test_connection(api_config: ApiConfigItem):
"""测试 API 连接并获取模型列表"""
try:
api_key_to_use = api_config.apiKey or ""
# 如果 API Key 为空,尝试从已保存的配置中获取
if not api_key_to_use and api_config.category:
# 遍历所有配置文件,找到包含该 category 的配置
for config_file in CONFIG_DIR.glob("*.json"):
try:
with open(config_file, 'r', encoding='utf-8') as f:
profile = json.load(f)
# 检查是否包含该 category
if api_config.category in profile.get("apis", {}):
api_key_to_use = profile["apis"][api_config.category].get("apiKey", "")
if api_key_to_use:
break
except Exception:
continue
# 检测提供商类型
provider = LLMModelService.detect_provider(api_config.apiUrl)
# 获取模型列表
models = LLMModelService.get_models_by_provider(
provider=provider,
api_key=api_config.apiKey or "",
api_key=api_key_to_use,
api_url=api_config.apiUrl
)

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

@@ -0,0 +1,512 @@
"""
聊天 WebSocket 路由
处理实时对话生成
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Dict, Any
import json
import asyncio
try:
from backend.services.chat_workflow_service import ChatWorkflowService
from backend.services.chat_service import ChatService
from backend.services.task_queue_manager import task_queue_manager
from backend.core.config import settings
except ImportError:
from services.chat_workflow_service import ChatWorkflowService
from services.chat_service import ChatService
from services.task_queue_manager import task_queue_manager
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
# 初始化服务
workflow_service = ChatWorkflowService()
chat_service = ChatService(settings.DATA_PATH)
# ✅ 全局变量:用于存储需要中断的聊天会话
interrupt_flags: Dict[str, bool] = {}
@router.websocket("/{role_name}/{chat_name}/ws")
async def websocket_chat_endpoint(
websocket: WebSocket,
role_name: str,
chat_name: str
):
"""
WebSocket 聊天端点
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
"""
await websocket.accept()
print(f"\n{'='*80}")
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
print(f"{'='*80}\n")
chat_id = f"{role_name}/{chat_name}"
try:
while True:
# 1. 接收前端消息
print(f"[WebSocket] ⏳ 等待接收消息...")
data = await websocket.receive_text()
print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}")
request_data = json.loads(data)
# ✅ 检查是否是取消任务的请求
if request_data.get("type") == "cancel_task":
task_id = request_data.get("taskId")
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
if task_id == "current_llm_generation":
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
# TODO: 实现 LLM 生成的中断逻辑
# 目前只能通过关闭连接来终止
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": True,
"message": "LLM 生成已终止"
})
else:
# 取消其他类型的任务(图像生成、动态表格等)
success = await task_queue_manager.cancel_task(task_id)
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": success
})
print(f"[WebSocket] ✅ 任务取消结果: {success}")
continue
print(f"\n{'-'*80}")
print(f"[WebSocket] 📨 收到请求:")
print(f" - Floor: {request_data.get('floor')}")
print(f" - Role: {request_data.get('currentRole')}")
print(f" - Chat: {request_data.get('currentChat')}")
print(f" - Stream: {request_data.get('stream', False)}")
print(f" - Message Length: {len(request_data.get('mes', ''))}")
# ✅ 打印 API 配置信息(隐藏密钥)
api_config = request_data.get('apiConfig', {})
current_profile = request_data.get('currentProfile', {})
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
print(f" - Profile ID: {profile_id or 'N/A'}")
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
print(f" - Model: {api_config.get('model', 'N/A')}")
# ✅ 始终从配置文件中读取 API Key不信任前端传来的 Key
if profile_id:
try:
from .apiConfigRoute import load_profile
profile = load_profile(profile_id)
if profile:
# 找到 mainLLM 的配置
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
api_key = main_llm_config.get('apiKey', '')
if api_key:
# 使用明文 API Key
api_config['api_key'] = api_key
request_data['apiConfig'] = api_config
print(f" - API Key: ✅ 已从配置文件加载")
else:
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
else:
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
except Exception as e:
print(f" - API Key: ❌ 加载失败: {str(e)}")
import traceback
traceback.print_exc()
else:
print(f" - API Key: ⚠️ 未提供 profileId无法加载")
print(f"{'-'*80}\n")
# 2. 提取流式输出标志
stream_output = request_data.get("stream", False)
if stream_output:
# === 真正的流式输出模式 ===
print(f"[WebSocket] 🌊 进入流式处理模式")
await _handle_stream_chat(
websocket, role_name, chat_name, request_data, workflow_service
)
else:
# === 非流式输出模式 ===
print(f"[WebSocket] 📦 进入非流式处理模式")
result = await workflow_service.process_chat_request(request_data)
if result["success"]:
content = result["content"]
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
# ✅ 发送激活的世界书条目信息
active_entries = result.get("activeEntries", [])
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
await websocket.send_json({
"type": "worldbook_active",
"entries": active_entries
})
# ✅ 发送任务ID信息
task_ids = result.get("taskIds", {})
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids
})
# 一次性发送完整内容
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
await websocket.send_json({
"type": "chunk",
"content": content
})
print(f"[WebSocket] ✅ 发送完成信号")
await websocket.send_json({
"type": "complete"
})
# 保存消息
print(f"[WebSocket] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[WebSocket] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg
})
except WebSocketDisconnect:
print(f"\n{'='*80}")
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
print(f"{'='*80}\n")
except Exception as e:
print(f"\n{'='*80}")
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
print(f"{'='*80}\n")
import traceback
traceback.print_exc()
try:
await websocket.send_json({
"type": "error",
"message": f"服务器错误: {str(e)}"
})
except:
pass
finally:
try:
await websocket.close()
except:
pass
async def _handle_stream_chat(
websocket: WebSocket,
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
workflow_service
):
"""
处理流式聊天请求
Args:
websocket: WebSocket 连接
role_name: 角色名
chat_name: 聊天名
request_data: 请求数据
workflow_service: 工作流服务实例
"""
try:
print(f"[StreamChat] 🚀 开始流式处理")
# ✅ 第1步加载角色卡
current_role = request_data.get("currentRole")
character_data = request_data.get("characterData")
if character_data:
try:
from backend.models.internal import CharacterCard
except ImportError:
from models.internal import CharacterCard
character = CharacterCard(**character_data)
else:
from backend.services.character_service import CharacterService
character_service = CharacterService()
character = character_service.get_character_by_name(current_role)
if not character:
print(f"[StreamChat] ❌ 错误: 无法加载角色 '{current_role}'")
await websocket.send_json({
"type": "error",
"message": f"角色 '{current_role}' 不存在"
})
return
print(f"[StreamChat] ✅ 已加载角色卡: {character.name}")
# ✅ 第2步激活世界书条目在LLM调用之前
print(f"[StreamChat] 📚 正在激活世界书条目...")
active_entries = await workflow_service._collect_and_activate_worldbooks(
request_data,
character
)
# ✅ 发送激活的世界书条目信息在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": entries_dict
})
# ✅ TODO: RAG检索暂时为空待实现
rag_results = []
if rag_results:
print(f"[StreamChat] 🔍 发送 RAG 检索结果: {len(rag_results)}")
await websocket.send_json({
"type": "rag_results",
"results": rag_results
})
# ✅ 第2步启动并行任务在LLM调用前创建任务ID
options = request_data.get("options", {})
task_ids = {
"imageWorkflow": None,
"dynamicTable": None
}
if options.get("imageWorkflow", False):
import uuid
chat_id = f"{role_name}/{chat_name}"
task_ids["imageWorkflow"] = f"img_{uuid.uuid4().hex[:8]}"
from backend.services.task_queue_manager import task_queue_manager, TaskType
await task_queue_manager.add_task(task_ids["imageWorkflow"], TaskType.IMAGE_WORKFLOW, chat_id)
if options.get("dynamicTable", False):
import uuid
chat_id = f"{role_name}/{chat_name}"
task_ids["dynamicTable"] = f"tbl_{uuid.uuid4().hex[:8]}"
from backend.services.task_queue_manager import task_queue_manager, TaskType
await task_queue_manager.add_task(task_ids["dynamicTable"], TaskType.DYNAMIC_TABLE, chat_id)
# ✅ 发送任务ID信息在LLM调用前
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids
})
# ✅ 第3步调用LLM流式生成
chunk_count = [0] # 使用列表以便在闭包中修改
result = await workflow_service.process_chat_request_stream(
request_data,
on_chunk=lambda chunk: asyncio.create_task(
_send_chunk_with_log(websocket, chunk, chunk_count)
)
)
if result["success"]:
content = result["content"]
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
# 发送完成信号
print(f"[StreamChat] ✅ 发送完成信号")
await websocket.send_json({
"type": "complete"
})
# 保存消息
print(f"[StreamChat] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[StreamChat] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg
})
except Exception as e:
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "error",
"message": f"流式处理失败: {str(e)}"
})
async def _send_chunk_with_log(websocket: WebSocket, chunk: str, chunk_count: list):
"""
发送 chunk 并记录日志
Args:
websocket: WebSocket 连接
chunk: 文本片段
chunk_count: 计数器(使用列表以便在闭包中修改)
"""
chunk_count[0] += 1
if chunk_count[0] % 10 == 0: # 每10个chunk记录一次
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
await websocket.send_json({"type": "chunk", "content": chunk})
async def _save_messages(
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
ai_response: str
):
"""
保存用户消息和AI回复到聊天文件
Args:
role_name: 角色名
chat_name: 聊天名
request_data: 前端发送的请求数据
ai_response: AI生成的回复
"""
try:
from datetime import datetime
# ✅ 应用双 false 的正则规则(永久修改存储数据)
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
# 获取预设名称
preset_config = request_data.get("presetConfig", {})
preset_name = preset_config.get("selectedPreset")
# 计算消息深度
floor = request_data.get("floor", 0)
message_depth = 0 # AI 回复是最新消息,深度为 0
# ✅ 应用 AI Output 正则规则placement=2
# 只应用双 false 的规则markdownOnly=false 且 promptOnly=false
processed_ai_response = regex_service.apply_rules_by_placement(
text=ai_response,
placement=RegexPlacement.AI_OUTPUT.value,
character_name=role_name,
preset_name=preset_name,
message_depth=message_depth,
is_for_llm=False, # ✅ 不是发送给 LLM是保存数据
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
)
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
if processed_ai_response != ai_response:
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
ai_response = processed_ai_response
# ✅ 检查是否是重roll模式targetFloor 存在且不为 null
target_floor = request_data.get("floor")
is_reroll = target_floor is not None
if is_reroll:
# ✅ 重roll模式更新现有消息的 swipes 数组
print(f"[WebSocket] 🔄 重roll模式更新楼层 {target_floor} 的 swipes")
# 获取现有的消息
existing_message = chat_service.get_message(role_name, chat_name, target_floor)
if not existing_message:
print(f"[WebSocket] ⚠️ 找不到楼层 {target_floor} 的消息,创建新消息")
# 如果找不到,创建新消息(兼容处理)
ai_message = {
"id": f"msg_{datetime.now().timestamp()}_ai",
"name": request_data.get("characterName", role_name),
"is_user": False,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": ai_response,
"chatId": f"{role_name}/{chat_name}",
"floor": target_floor,
"swipes": [ai_response],
"swipe_id": 0
}
chat_service.add_message(role_name, chat_name, ai_message)
else:
# ✅ 更新 swipes 数组
existing_swipes = existing_message.get("swipes", [])
current_mes = existing_message.get("mes", "")
# 构建新的 swipes 数组
updated_swipes = list(existing_swipes) # 复制现有swipes
# 如果当前 mes 不在 swipes 中,先添加它
if current_mes and current_mes not in updated_swipes:
updated_swipes.append(current_mes)
print(f"[WebSocket] 📝 将当前内容添加到 swipes")
# 添加新生成的内容
updated_swipes.append(ai_response)
print(f"[WebSocket] 📊 Swipes 更新: {len(existing_swipes)} -> {len(updated_swipes)}")
# 更新消息
update_data = {
"mes": ai_response, # 显示最新内容
"swipes": updated_swipes, # 更新 swipes 数组
"swipe_id": len(updated_swipes) - 1 # 自动切换到新版本
}
chat_service.update_message(role_name, chat_name, target_floor, update_data)
print(f"[WebSocket] ✅ 楼层 {target_floor} 已更新swipes 数量: {len(updated_swipes)}")
else:
# ✅ 正常模式创建新的用户消息和AI消息
print(f"[WebSocket] 正常模式,创建新消息")
# 1. 保存用户消息
user_message = {
"id": f"msg_{datetime.now().timestamp()}_user",
"name": request_data.get("userName", "User"),
"is_user": True,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": request_data.get("mes", ""),
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0)
}
chat_service.add_message(role_name, chat_name, user_message)
# 2. 保存AI回复
ai_message = {
"id": f"msg_{datetime.now().timestamp()}_ai",
"name": request_data.get("characterName", role_name),
"is_user": False,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": ai_response,
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0) + 1
}
chat_service.add_message(role_name, chat_name, ai_message)
print(f"[WebSocket] ✅ 新消息已保存: {role_name}/{chat_name}")
except Exception as e:
print(f"[WebSocket] 保存消息失败: {e}")
import traceback
traceback.print_exc()
# 不抛出异常,避免影响主流程

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException, status
from pathlib import Path
try:
from backend.services.chat_service import ChatService
from backend.core.config import settings
@@ -14,11 +15,23 @@ router = APIRouter(prefix="/chat", tags=["chat"])
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
chat_service = ChatService(data_path)
@router.get("", response_model=dict)
async def list_all_chats():
"""获取所有角色的所有聊天列表"""
return chat_service.list_all_chats()
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
try:
return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}")
async def list_role_chats(role_name: str):
"""获取指定角色的所有聊天列表"""
@@ -30,13 +43,6 @@ async def list_role_chats(role_name: str):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
try:
return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
async def create_chat(role_name: str, chat_data: dict):
@@ -48,18 +54,21 @@ async def create_chat(role_name: str, chat_data: dict):
except FileExistsError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}")
async def update_chat(role_name: str, chat_name: str, update_data: dict):
"""更新聊天元数据"""
# TODO: 实现更新聊天元数据功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.delete("/{role_name}/{chat_name}")
async def delete_chat(role_name: str, chat_name: str):
"""删除指定聊天"""
# TODO: 实现删除聊天功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{role_name}/{chat_name}/messages")
async def list_messages(role_name: str, chat_name: str):
"""获取聊天的所有消息"""
@@ -69,6 +78,7 @@ async def list_messages(role_name: str, chat_name: str):
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}/{chat_name}/messages/{floor}")
async def get_message(role_name: str, chat_name: str, floor: int):
"""获取指定楼层的消息"""
@@ -81,6 +91,7 @@ async def get_message(role_name: str, chat_name: str, floor: int):
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
async def add_message(role_name: str, chat_name: str, message_data: dict):
"""向聊天添加新消息"""
@@ -91,6 +102,7 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}/messages/{floor}")
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
"""更新指定楼层的消息"""
@@ -101,6 +113,7 @@ async def update_message(role_name: str, chat_name: str, floor: int, update_data
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
async def delete_message(role_name: str, chat_name: str, floor: int):
"""删除指定楼层的消息"""
@@ -110,3 +123,56 @@ async def delete_message(role_name: str, chat_name: str, floor: int):
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{role_name}/{chat_name}/table")
async def update_table_data(role_name: str, chat_name: str, table_update: dict):
"""更新表格数据(带时间戳冲突解决)"""
try:
return chat_service.update_table_data(role_name, chat_name, table_update)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
"""
创建聊天分支
复制当前楼层及之前的所有内容到一个新的聊天记录
Args:
role_name: 角色名称
chat_name: 原聊天名称
branch_data: {
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
}
Returns:
{
"success": bool,
"new_chat_name": str,
"message_count": int
}
"""
try:
target_floor = branch_data.get("target_floor")
new_chat_name = branch_data.get("new_chat_name")
if target_floor is None:
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
# 调用服务层创建分支
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")

View File

@@ -0,0 +1,140 @@
"""
图片画廊路由
提供图片查询、删除等管理接口
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, List, Optional
import os
try:
from backend.services.image_metadata_service import image_metadata_service
except ImportError:
from services.image_metadata_service import image_metadata_service
router = APIRouter(prefix="/image-gallery", tags=["image-gallery"])
@router.get("/stats")
async def get_gallery_stats():
"""获取画廊统计信息"""
return await image_metadata_service.get_gallery_stats()
@router.get("/images/{chat_id}")
async def get_chat_images(
chat_id: str,
floor: Optional[int] = None
):
"""
获取指定聊天的图片列表
Args:
chat_id: 聊天ID (role_name/chat_name)
floor: 楼层号(可选)
"""
images = await image_metadata_service.get_images_by_chat(chat_id, floor)
return {
"chatId": chat_id,
"totalImages": len(images),
"images": [img.model_dump() for img in images]
}
@router.get("/images/role/{role_name}")
async def get_role_images(role_name: str):
"""获取指定角色的所有图片"""
images = await image_metadata_service.get_images_by_role(role_name)
return {
"roleName": role_name,
"totalImages": len(images),
"images": [img.model_dump() for img in images]
}
@router.delete("/images/{chat_id}/{image_id}")
async def delete_image(chat_id: str, image_id: str):
"""
删除图片(元数据和文件)
Args:
chat_id: 聊天ID
image_id: 图片ID
"""
# 先获取元数据以得到文件路径
images = await image_metadata_service.get_images_by_chat(chat_id)
target_image = None
for img in images:
if img.id == image_id:
target_image = img
break
if not target_image:
raise HTTPException(status_code=404, detail="图片不存在")
# 删除元数据
success = await image_metadata_service.delete_image(chat_id, image_id)
if not success:
raise HTTPException(status_code=500, detail="删除失败")
# 删除实际文件
try:
file_path = image_metadata_service.get_image_full_path(target_image.filepath)
if file_path.exists():
file_path.unlink()
except Exception as e:
print(f"[ImageGallery] 删除文件失败: {e}")
# 不抛出异常,因为元数据已删除
return {"message": "图片已删除"}
@router.post("/images/{chat_id}/clear")
async def clear_chat_images(chat_id: str):
"""
清空指定聊天的所有图片
Args:
chat_id: 聊天ID
"""
count = await image_metadata_service.clear_chat_images(chat_id)
return {
"message": f"已清空 {count} 张图片",
"deletedCount": count
}
@router.post("/images/{chat_id}/{image_id}/set-current")
async def set_current_swipe(chat_id: str, image_id: str):
"""
设置某张图片为当前显示的 swipe
Args:
chat_id: 聊天ID
image_id: 图片ID
"""
success = await image_metadata_service.set_current_swipe(chat_id, image_id)
if not success:
raise HTTPException(status_code=404, detail="图片不存在")
return {"message": "已设置为当前显示"}
@router.get("/image/{filepath:path}")
async def get_image(filepath: str):
"""
获取图片文件
Args:
filepath: 文件相对路径
"""
from fastapi.responses import FileResponse
file_path = image_metadata_service.get_image_full_path(filepath)
if not file_path.exists():
raise HTTPException(status_code=404, detail="图片文件不存在")
return FileResponse(str(file_path))

View File

@@ -1,37 +1,26 @@
from fastapi import APIRouter, HTTPException, status
# TODO: 实现 PresetService 来替代旧的 AIDesignSpec 逻辑
# from services.preset_service import PresetService
from services.preset_service import PresetService
router = APIRouter(prefix="/presets", tags=["presets"])
@router.get("", response_model=dict)
async def list_presets():
"""获取所有预设列表及其基本信息"""
# return await PresetService.list_all_presets()
return {"presets": []}
try:
presets = PresetService.list_presets()
response_data = {"presets": presets}
print(f"[API] GET /api/presets - 返回数据: {response_data}")
return response_data
except Exception as e:
print(f"[API] GET /api/presets - 错误: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{preset_name}")
async def get_preset(preset_name: str):
"""获取指定预设的完整内容"""
# try:
# return await PresetService.get_preset(preset_name)
# except FileNotFoundError:
# raise HTTPException(status_code=404, detail="Preset not found")
raise HTTPException(status_code=501, detail="Not Implemented")
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_preset(preset_name: str, preset_data: dict):
"""创建新预设"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.put("/{preset_name}")
async def update_preset(preset_name: str, update_data: dict):
"""更新预设配置"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.delete("/{preset_name}")
async def delete_preset(preset_name: str):
"""删除指定预设"""
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{preset_name}/components/{component_id}")
async def get_preset_component(preset_name: str, component_id: str):
"""获取指定组件的详情"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{preset_name}/components")
@@ -39,10 +28,78 @@ async def list_preset_components(preset_name: str):
"""获取预设中的所有组件"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{preset_name}/components/{component_id}")
async def get_preset_component(preset_name: str, component_id: str):
"""获取指定组件的详情"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{preset_name}")
async def get_preset(preset_name: str):
"""获取指定预设的完整内容"""
try:
preset_data = PresetService.get_preset(preset_name)
return preset_data
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("", status_code=status.HTTP_201_CREATED)
async def create_preset(preset_data: dict):
"""创建新预设"""
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):
"""更新预设配置"""
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.post("/{preset_name}/rename")
async def rename_preset(preset_name: str, rename_data: dict):
"""重命名预设(同时修改文件名和内部 name 字段)"""
try:
new_name = rename_data.get("newName")
if not new_name:
raise HTTPException(status_code=400, detail="newName is required")
# 清理新名称(去掉可能的时间戳和后缀)
import re
clean_name = re.sub(r'_\d{10,13}$', '', new_name.replace('.json', ''))
updated_preset = PresetService.rename_preset(preset_name, clean_name)
return {"success": True, "preset": updated_preset, "newName": clean_name}
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
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.delete("/{preset_name}")
async def delete_preset(preset_name: str):
"""删除指定预设"""
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):
@@ -58,3 +115,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

@@ -0,0 +1,387 @@
"""
正则规则 API 路由
提供正则规则的 CRUD 操作和导入导出功能
"""
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import List, Optional
import json
import logging
from services.regex_service import regex_service
from models.regex_rules import RegexRule, RegexRuleset, RegexScope
from services.system_settings_service import system_settings_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/regex", tags=["regex"])
# ==================== 数据模型 ====================
class RuleUpdateRequest(BaseModel):
"""规则更新请求"""
rule: RegexRule
scope: RegexScope
name: Optional[str] = None # 角色卡名称或预设名称scope 为 CHARACTER/PRESET 时需要)
class SystemSettingsUpdate(BaseModel):
"""系统设置更新请求"""
thinkingTagPrefix: Optional[str] = None
thinkingTagSuffix: Optional[str] = None
currentPresetName: Optional[str] = None
# ==================== 规则查询 ====================
@router.get("/rules")
async def get_rules(
character_name: Optional[str] = None,
preset_name: Optional[str] = None
):
"""
获取适用的正则规则列表
Args:
character_name: 当前角色卡名称(可选)
preset_name: 当前预设名称(可选)
Returns:
规则列表
"""
try:
rules = regex_service.get_rules_for_context(character_name, preset_name)
return {
"success": True,
"rules": [rule.dict() for rule in rules],
"count": len(rules)
}
except Exception as e:
logger.error(f"获取规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/global")
async def get_global_rulesets():
"""获取所有全局规则集"""
try:
rulesets = list(regex_service.global_rulesets.values())
return {
"success": True,
"rulesets": [rs.dict() for rs in rulesets]
}
except Exception as e:
logger.error(f"获取全局规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/character/{character_name}")
async def get_character_ruleset(character_name: str):
"""获取指定角色卡的规则集"""
try:
if character_name in regex_service.character_rulesets:
ruleset = regex_service.character_rulesets[character_name]
return {
"success": True,
"ruleset": ruleset.dict()
}
else:
return {
"success": True,
"ruleset": None
}
except Exception as e:
logger.error(f"获取角色规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/preset/{preset_name}")
async def get_preset_ruleset(preset_name: str):
"""获取指定预设的规则集"""
try:
if preset_name in regex_service.preset_rulesets:
ruleset = regex_service.preset_rulesets[preset_name]
return {
"success": True,
"ruleset": ruleset.dict()
}
else:
return {
"success": True,
"ruleset": None
}
except Exception as e:
logger.error(f"获取预设规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== 规则管理 ====================
@router.post("/rules")
async def add_rule(request: RuleUpdateRequest):
"""添加或更新规则"""
try:
# 获取现有的规则集
existing_ruleset = None
if request.scope == RegexScope.GLOBAL:
# 对于全局作用域,查找是否已有同名规则集
for ruleset_id, ruleset in regex_service.global_rulesets.items():
if ruleset.name == request.rule.scriptName:
existing_ruleset = ruleset
break
elif request.scope == RegexScope.CHARACTER and request.name:
if request.name in regex_service.character_rulesets:
existing_ruleset = regex_service.character_rulesets[request.name]
elif request.scope == RegexScope.PRESET and request.name:
if request.name in regex_service.preset_rulesets:
existing_ruleset = regex_service.preset_rulesets[request.name]
if existing_ruleset:
# 如果已存在同名规则集,则更新其中的规则
updated_rules = []
rule_found = False
for rule in existing_ruleset.rules:
if rule.id == request.rule.id:
# 更新现有规则
updated_rules.append(request.rule)
rule_found = True
else:
# 保留其他规则
updated_rules.append(rule)
if not rule_found:
# 如果没有找到相同ID的规则则添加新规则
updated_rules.append(request.rule)
# 更新规则集
existing_ruleset.rules = updated_rules
regex_service.save_ruleset(existing_ruleset, request.scope, request.name)
else:
# 如果不存在同名规则集,则创建新的规则集
new_ruleset = RegexRuleset(
id=request.rule.id,
name=request.rule.scriptName,
rules=[request.rule]
)
regex_service.save_ruleset(new_ruleset, request.scope, request.name)
# 重新加载规则
regex_service._load_all_rules()
return {
"success": True,
"message": "规则保存成功"
}
except Exception as e:
logger.error(f"保存规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rules/{rule_id}")
async def delete_rule(rule_id: str, scope: str = "global", name: Optional[str] = None):
"""
删除规则
Args:
rule_id: 规则ID
scope: 作用域 (global/character/preset)
name: 角色名或预设名scope 为 character/preset 时需要)
"""
try:
from models.regex_rules import RegexScope
scope_map = {
"global": RegexScope.GLOBAL,
"character": RegexScope.CHARACTER,
"preset": RegexScope.PRESET
}
scope_enum = scope_map.get(scope, RegexScope.GLOBAL)
# 找到包含该规则的规则集
if scope_enum == RegexScope.GLOBAL:
rulesets = regex_service.global_rulesets
elif scope_enum == RegexScope.CHARACTER:
if not name:
raise ValueError("删除角色规则需要提供角色名称")
rulesets = {name: regex_service.character_rulesets.get(name)} if name in regex_service.character_rulesets else {}
elif scope_enum == RegexScope.PRESET:
if not name:
raise ValueError("删除预设规则需要提供预设名称")
rulesets = {name: regex_service.preset_rulesets.get(name)} if name in regex_service.preset_rulesets else {}
# 查找并删除规则
deleted = False
for ruleset_name, ruleset in rulesets.items():
if not ruleset:
continue
original_count = len(ruleset.rules)
ruleset.rules = [r for r in ruleset.rules if r.id != rule_id]
if len(ruleset.rules) < original_count:
# 保存更新后的规则集
regex_service.save_ruleset(ruleset, scope_enum, ruleset_name if scope_enum != RegexScope.GLOBAL else None)
deleted = True
break
if not deleted:
return {
"success": False,
"message": "未找到指定的规则"
}
# 重新加载规则
regex_service._load_all_rules()
return {
"success": True,
"message": "规则已删除"
}
except Exception as e:
logger.error(f"删除规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== 规则导入导出 ====================
@router.post("/import")
async def import_rules(file: UploadFile = File(...)):
"""
导入正则规则(支持 SillyTavern 格式)- 文件上传方式
可以导入:
1. 单个规则文件JSON 数组)
2. 规则集文件JSON 对象)
"""
try:
content = await file.read()
data = json.loads(content.decode('utf-8'))
# 判断格式并导入
if isinstance(data, list):
# SillyTavern 格式 - 导入为全局规则
ruleset = regex_service._convert_sillytavern_format(data, file.filename.replace('.json', ''))
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
elif isinstance(data, dict):
if 'rules' in data:
# 规则集格式
ruleset = RegexRuleset(**data)
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
else:
raise ValueError("未知的文件格式")
else:
raise ValueError("无效的文件格式")
# 重新加载规则
regex_service._load_all_rules()
return {
"success": True,
"message": f"成功导入规则集: {ruleset.name}"
}
except Exception as e:
logger.error(f"导入规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import-from-preset")
async def import_rules_from_preset(request: dict):
"""
从预设导入正则规则 - JSON 数据方式
Request Body:
{
"rules": [...], // SillyTavern 格式的 regex_scripts 数组
"scope": "preset", // 作用域global/character/preset
"presetName": "预设名称" // 当 scope 为 preset 时需要
}
"""
try:
rules_data = request.get("rules", [])
scope_str = request.get("scope", "global")
preset_name = request.get("presetName")
if not rules_data or not isinstance(rules_data, list):
raise ValueError("无效的规则数据")
# 转换作用域字符串为枚举
scope_map = {
"global": RegexScope.GLOBAL,
"character": RegexScope.CHARACTER,
"preset": RegexScope.PRESET
}
scope = scope_map.get(scope_str, RegexScope.GLOBAL)
# 转换 SillyTavern 格式
name = preset_name or "imported_rules"
ruleset = regex_service._convert_sillytavern_format(rules_data, name, scope)
# 保存规则集
regex_service.save_ruleset(ruleset, scope, name)
# 重新加载规则
regex_service._load_all_rules()
logger.info(f"✅ 从预设导入 {len(rules_data)} 条正则规则到 {scope.value}: {name}")
return {
"success": True,
"message": f"成功导入 {len(rules_data)} 条正则规则",
"rulesetId": ruleset.id
}
except Exception as e:
logger.error(f"从预设导入规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/export/global")
async def export_global_rules():
"""导出所有全局规则"""
try:
all_rulesets = list(regex_service.global_rulesets.values())
return {
"success": True,
"rulesets": [rs.dict() for rs in all_rulesets]
}
except Exception as e:
logger.error(f"导出规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== 系统设置 ====================
@router.get("/settings")
async def get_system_settings():
"""获取系统设置"""
try:
settings = system_settings_service.settings
return {
"success": True,
"settings": settings.dict()
}
except Exception as e:
logger.error(f"获取系统设置失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/settings")
async def update_system_settings(request: SystemSettingsUpdate):
"""更新系统设置"""
try:
if request.thinkingTagPrefix is not None or request.thinkingTagSuffix is not None:
prefix = request.thinkingTagPrefix or system_settings_service.settings.thinkingTagPrefix
suffix = request.thinkingTagSuffix or system_settings_service.settings.thinkingTagSuffix
system_settings_service.update_thinking_tags(prefix, suffix)
if request.currentPresetName is not None:
system_settings_service.update_current_preset(request.currentPresetName)
return {
"success": True,
"message": "系统设置已更新"
}
except Exception as e:
logger.error(f"更新系统设置失败: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,130 @@
"""
Token 使用统计路由
提供 token 使用情况的查询接口
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, List, Optional
try:
from backend.services.token_usage_service import token_usage_service
except ImportError:
from services.token_usage_service import token_usage_service
router = APIRouter(prefix="/token-usage", tags=["token-usage"])
@router.get("/months")
async def list_months():
"""列出所有有数据的月份"""
return await token_usage_service.list_months()
@router.get("/stats/{year}/{month}")
async def get_monthly_stats(
year: int,
month: int,
role_name: Optional[str] = None,
chat_name: Optional[str] = None
):
"""
获取指定月份的统计数据
Args:
year: 年份
month: 月份
role_name: 角色名称(可选)
chat_name: 聊天名称(可选)
"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
try:
stats = await token_usage_service.get_stats_by_month(
year=year,
month=month,
role_name=role_name,
chat_name=chat_name
)
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}")
@router.get("/api-urls")
async def get_api_url_stats():
"""
✅ 获取按 API URL 分组的统计数据(快速查询)
Returns:
{
"api_url_1": {
"totalPromptTokens": 1000,
"totalCompletionTokens": 2000,
"totalTokens": 3000,
"count": 10,
"firstUsed": 1234567890,
"lastUsed": 1234567899
},
...
}
"""
try:
stats = await token_usage_service.get_api_url_stats()
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取 API URL 统计失败: {str(e)}")
@router.get("/daily/{year}/{month}")
async def get_daily_stats(year: int, month: int):
"""
✅ 获取指定月份的每日统计数据(快速查询)
Args:
year: 年份
month: 月份
Returns:
{
"2024-01-01": {
"promptTokens": 1000,
"completionTokens": 2000,
"totalTokens": 3000,
"count": 10
},
...
}
"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
try:
stats = await token_usage_service.get_daily_stats(year, month)
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取每日统计失败: {str(e)}")
@router.get("/roles/{year}/{month}")
async def get_available_roles(year: int, month: int):
"""获取指定月份有数据的角色列表"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
roles = await token_usage_service.get_available_roles(year, month)
return {"roles": roles}
@router.get("/chats/{year}/{month}")
async def get_available_chats(
year: int,
month: int,
role_name: Optional[str] = None
):
"""获取指定月份有数据的聊天列表"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
chats = await token_usage_service.get_available_chats(year, month, role_name)
return {"chats": chats}

View File

@@ -38,6 +38,80 @@ async def list_worldbooks():
logger.error(f"Failed to list worldbooks: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def get_worldbook_entry(name: str, uid: str):
"""
获取世界书的指定条目
"""
try:
return worldbook_service.get_entry(name, uid)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
"""
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
"""
try:
return worldbook_service.list_entries(name, page, page_size)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/export")
async def export_worldbook(name: str, format: str = "internal"):
"""
导出世界书(支持 internal 和 sillytavern 两种格式)
Args:
name: 世界书名称
format: 导出格式 ('internal''sillytavern'),默认 internal
"""
try:
if format.lower() == "sillytavern":
# 导出为 SillyTavern 格式(可能丢失特殊设置)
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
st_data = worldbook_service.export_to_sillytavern(name)
return JSONResponse(
content=st_data,
headers={
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
}
)
else:
# 导出为内部格式(保留所有设置)
logger.info(f"导出世界书 '{name}' 为内部格式")
internal_data = worldbook_service.get_worldbook(name)
return JSONResponse(
content=internal_data,
headers={
"Content-Disposition": f"attachment; filename={name}.json"
}
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}", response_model=Dict[str, Any])
async def get_worldbook(name: str):
"""
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
except Exception as e:
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/export")
async def export_worldbook(name: str, format: str = "internal"):
"""
导出世界书(支持 internal 和 sillytavern 两种格式)
Args:
name: 世界书名称
format: 导出格式 ('internal''sillytavern'),默认 internal
"""
try:
if format.lower() == "sillytavern":
# 导出为 SillyTavern 格式(可能丢失特殊设置)
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
st_data = worldbook_service.export_to_sillytavern(name)
return JSONResponse(
content=st_data,
headers={
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
}
)
else:
# 导出为内部格式(保留所有设置)
logger.info(f"导出世界书 '{name}' 为内部格式")
internal_data = worldbook_service.get_worldbook(name)
return JSONResponse(
content=internal_data,
headers={
"Content-Disposition": f"attachment; filename={name}.json"
}
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,12 +3,15 @@ from pathlib import Path
from dotenv import load_dotenv
# 1. 动态计算项目根目录
# 假设 config.py 位于 backend/core/ 目录下
# __file__ 指向本文件的绝对路径
# .parent 指向 backend/core/ 目录
# .parent.parent 指向 backend/ 目录
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# 在 Docker 环境中config.py 位于 /app/core/需要向上2级到 /app/
# 在本地开发中config.py 位于 backend/core/需要向上3级到项目根目录
_config_path = Path(__file__).resolve()
if _config_path.parent.parent.name == 'app':
# Docker 环境:/app/core/config.py -> /app/
PROJECT_ROOT = _config_path.parent.parent
else:
# 本地开发backend/core/config.py -> 项目根目录
PROJECT_ROOT = _config_path.parent.parent.parent
# 2. 加载 .env 文件
# 假设 .env 文件位于项目根目录下
@@ -16,13 +19,6 @@ load_dotenv(PROJECT_ROOT / ".env")
class Settings:
# --- 主模型配置 ---
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
# --- 路径配置 (核心修改) ---
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
@@ -35,7 +31,8 @@ class Settings:
STATE_FILE = DATA_PATH / "state.json"
SCHEMA_FILE = DATA_PATH / "schema.json"
PRESETS_FILE = DATA_PATH / "presets.json"
REGEX_FILE = DATA_PATH / "regex_rules.json"
REGEX_FILE = DATA_PATH / "regex_rules.json" # 正则规则文件
SYSTEM_SETTINGS_FILE = DATA_PATH / "system_settings.json" # 系统设置文件
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
# --- 业务数据目录 ---
@@ -46,17 +43,20 @@ class Settings:
# 预设目录
PRESET_PATH = DATA_PATH / "preset"
# 聊天记录目录
# 聊天记录目录(同时存放角色卡和聊天)
CHAT_PATH = DATA_PATH / "chat"
# 兼容别名:用于代码中引用
CHATS_PATH = CHAT_PATH
# 临时文件目录
TEMP_PATH = DATA_PATH / "temp"
# ComfyUI 工作流目录
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
# 角色卡目录
CHARACTERS_PATH = DATA_PATH / "characters"
# 角色卡目录(已合并到 CHAT_PATH
CHARACTERS_PATH = CHAT_PATH
# 图片资源目录
IMAGES_PATH = DATA_PATH / "images"
@@ -76,6 +76,10 @@ class Settings:
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
# 确保核心数据文件的父目录存在
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
file_path.parent.mkdir(parents=True, exist_ok=True)
settings = Settings()

View File

@@ -136,11 +136,23 @@ class CharacterCard(BaseModel):
first_mes: str = Field(..., description="首条开场消息")
mes_example: str = Field(..., description="对话示例")
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (SillyTavern 关键字机制)")
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
avatarPath: Optional[str] = Field(None, description="角色头像路径")
alternate_greetings: Optional[List[str]] = Field(None, description="替代问候语数组")
tags: Optional[List[str]] = Field(None, description="标签数组")
# TODO: 拓展提示词设置(插件/拓展系统预留接口)
# - tableMaintenancePrompt: 用于指导 AI 维护动态表格RPG状态、任务追踪等
# - imageGenerationPrompt: 用于指导 AI 生成图片描述提示词
# 当前状态:字段已定义,默认值为 None等待插件系统实现
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="最后聊天时间戳")
@@ -150,18 +162,54 @@ 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="显示名称 (聊天标题)")
characterId: str = Field(..., description="关联的角色卡 ID")
userName: str = Field("User", description="用户角色名")
characterName: str = Field(..., description="AI 角色名称")
tableData: Optional[Dict[str, Any]] = Field(None, description="表格数据 (对应 outputSchema)")
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="消息数量")
@@ -172,7 +220,7 @@ class ChatMessage(BaseModel):
"""
项目内部聊天消息
单条对话消息,支持多版本 (swipes)、token 统计等功能。
单条对话消息支持多版本 (swipes)、token 统计、历史记录总结等功能。
"""
id: str = Field(..., description="消息唯一标识符 (UUID)")
name: str = Field(..., description="发送者名称")
@@ -186,6 +234,11 @@ class ChatMessage(BaseModel):
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):
"""
@@ -299,3 +352,84 @@ class ChatRAGConfig(BaseModel):
indexConfig: 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="最后更新时间戳")
# ==================== Token 统计 ====================
class TokenUsageStatus(str, Enum):
"""Token 使用状态"""
COMPLETED = 'completed' # 成功完成
INTERRUPTED = 'interrupted' # 被用户中断
FAILED = 'failed' # 请求失败API错误等
class TokenUsageRecord(BaseModel):
"""
Token 使用记录
记录每次 LLM 调用的 token 使用情况,支持按时间、角色、聊天维度统计
"""
id: str = Field(..., description="记录唯一标识符 (UUID)")
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
roleName: str = Field(..., description="角色名称")
chatName: str = Field(..., description="聊天名称")
messageId: Optional[str] = Field(None, description="关联的消息ID")
floor: Optional[int] = Field(None, description="楼层号")
# Token 统计
promptTokens: int = Field(0, description="输入 token 数")
completionTokens: int = Field(0, description="输出 token 数")
totalTokens: int = Field(0, description="总 token 数")
# 状态信息
status: TokenUsageStatus = Field(TokenUsageStatus.COMPLETED, description="请求状态")
errorMessage: Optional[str] = Field(None, description="错误信息(如果失败)")
# 时间信息
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="请求时间戳")
duration: Optional[float] = Field(None, description="请求耗时(秒)")
# API 信息
model: Optional[str] = Field(None, description="使用的模型")
apiProvider: Optional[str] = Field(None, description="API 提供商")
apiUrl: Optional[str] = Field(None, description="API URL地址")
# ==================== 图片元数据 ====================
class ImageMetadata(BaseModel):
"""
图片元数据
记录生成的图片信息,绑定到角色/聊天的特定楼层
"""
id: str = Field(..., description="图片唯一标识符 (UUID)")
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
roleName: str = Field(..., description="角色名称")
chatName: str = Field(..., description="聊天名称")
floor: int = Field(..., description="楼层号")
# 图片信息
filename: str = Field(..., description="文件名")
filepath: str = Field(..., description="文件相对路径")
width: Optional[int] = Field(None, description="图片宽度")
height: Optional[int] = Field(None, description="图片高度")
fileSize: Optional[int] = Field(None, description="文件大小(字节)")
# Swipe 支持
swipeIndex: int = Field(0, description="Swipe 索引(同一楼层多张图片)")
isCurrentSwipe: bool = Field(True, description="是否为当前显示的 swipe")
# 生成信息
prompt: Optional[str] = Field(None, description="生成使用的提示词")
negativePrompt: Optional[str] = Field(None, description="负面提示词")
seed: Optional[int] = Field(None, description="随机种子")
model: Optional[str] = Field(None, description="使用的模型/checkpoint")
workflowName: Optional[str] = Field(None, description="使用的工作流名称")
# 任务信息
taskId: Optional[str] = Field(None, description="关联的任务ID")
generationTime: Optional[float] = Field(None, description="生成耗时(秒)")
# 时间信息
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")

View File

@@ -0,0 +1,164 @@
"""
正则替换规则模型
兼容 SillyTavern 的正则系统,支持全局、角色卡、预设三种作用域。
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
class RegexPlacement(int, Enum):
"""
正则应用位置(对应 SillyTavern 的 placement 数组)
0: System Prompt - 系统提示词
1: User Input - 用户输入
2: AI Output - AI 输出
3: Quick Reply - 快捷回复
4: World Info - 世界书信息
5: Reasoning/Thinking - 推理/思考内容
"""
SYSTEM_PROMPT = 0
USER_INPUT = 1
AI_OUTPUT = 2
QUICK_REPLY = 3
WORLD_INFO = 4
REASONING = 5
class RegexScope(str, Enum):
"""
正则规则作用域
- GLOBAL: 全局生效,对所有聊天应用
- CHARACTER: 绑定到特定角色卡
- PRESET: 绑定到特定预设
"""
GLOBAL = 'global'
CHARACTER = 'character'
PRESET = 'preset'
class SubstituteMode(int, Enum):
"""
替换模式
对应 SillyTavern 的 substituteRegex 字段
"""
REPLACE_ALL = 0 # 替换所有匹配
REPLACE_FIRST = 1 # 仅替换首次匹配
REPLACE_CAPTURED = 2 # 替换捕获组
class RegexRule(BaseModel):
"""
单条正则替换规则
完全兼容 SillyTavern 的正则规则格式
"""
id: str = Field(..., description="规则唯一标识符 (UUID)")
scriptName: str = Field(..., description="脚本名称(用于显示)")
# 核心正则配置
findRegex: str = Field(..., description="查找正则表达式(如:/<thinking>[\\s\\S]*?<\\/thinking>/gi")
replaceString: str = Field("", description="替换字符串(支持捕获组引用 $1, $2 等)")
trimStrings: List[str] = Field(default_factory=list, description="要额外修剪的字符串数组")
# 应用位置(关键!对应 SillyTavern 的 placement 数组)
placement: List[RegexPlacement] = Field(
default_factory=lambda: [RegexPlacement.AI_OUTPUT],
description="应用位置数组0=系统提示词, 1=用户输入, 2=AI输出, 3=快捷回复, 4=世界书, 5=推理内容"
)
# 替换模式
substituteRegex: SubstituteMode = Field(
SubstituteMode.REPLACE_ALL,
description="替换模式0=全部1=首次2=捕获组"
)
# 作用范围控制
markdownOnly: bool = Field(False, description="是否仅应用于 Markdown 渲染后的内容")
promptOnly: bool = Field(False, description="是否仅应用于发送给 LLM 的提示词")
runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用")
# 消息深度控制
minDepth: Optional[int] = Field(None, ge=0, description="最小消息深度从最新消息开始计数None 表示无限制)")
maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度None 表示无限制)")
# 作用域配置
scope: RegexScope = Field(RegexScope.GLOBAL, description="规则作用域")
characterName: Optional[str] = Field(None, description="绑定的角色卡名称scope=CHARACTER 时使用)")
presetName: Optional[str] = Field(None, description="绑定的预设名称scope=PRESET 时使用)")
# 启用状态
disabled: bool = Field(False, description="是否禁用此规则(与 enabled 相反,为了兼容 ST")
# 执行顺序
order: int = Field(0, description="执行顺序(数值越小越先执行)")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
description: Optional[str] = Field(None, description="规则描述(可选)")
class RegexRuleset(BaseModel):
"""
正则规则集
一组正则规则的集合,可以整体导入/导出,兼容 SillyTavern 格式
"""
id: str = Field(..., description="规则集唯一标识符 (UUID)")
name: str = Field(..., description="规则集名称")
description: Optional[str] = Field(None, description="规则集描述")
rules: List[RegexRule] = Field(default_factory=list, description="规则列表")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号(用于数据迁移)")
# SillyTavern 兼容性标记
isSillyTavernFormat: bool = Field(False, description="是否为 SillyTavern 导入格式")
# ==================== 使用示例 ====================
if __name__ == '__main__':
import json
# 创建一条规则
rule = RegexRule(
id="example-hide-thinking-001",
scriptName="隐藏思考标签",
findRegex=r"<thinking>[\s\S]*?<\/thinking>",
replaceString="",
trimStrings=[],
placement=[RegexPlacement.AI_OUTPUT],
substituteRegex=SubstituteMode.REPLACE_ALL,
markdownOnly=False,
promptOnly=False,
runOnEdit=True,
minDepth=0,
maxDepth=None,
scope=RegexScope.GLOBAL,
characterName=None,
presetName=None,
disabled=False,
order=1,
description="隐藏 AI 回复中的 <thinking> 标签及其内容"
)
# 创建规则集
ruleset = RegexRuleset(
id="ruleset-001",
name="默认正则规则集",
description="包含常用的文本处理规则",
rules=[rule]
)
# 导出为 JSON兼容 SillyTavern
print(json.dumps(ruleset.dict(), indent=2, ensure_ascii=False))

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

@@ -0,0 +1,49 @@
"""
系统设置模型
包含全局配置,如思考标签前后缀等。
"""
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
class SystemSettings(BaseModel):
"""
系统全局设置
持久化存储到 data/system_settings.json
"""
# ==================== 思考标签配置 ====================
thinkingTagPrefix: str = Field(
"<thinking>",
description="思考标签前缀(默认:<thinking>"
)
thinkingTagSuffix: str = Field(
"</thinking>",
description="思考标签后缀(默认:</thinking>"
)
# ==================== 当前选中的预设 ====================
currentPresetName: Optional[str] = Field(
None,
description="当前选中的预设名称(用于确定全局正则的作用域)"
)
# ==================== 元数据 ====================
updatedAt: int = Field(
default_factory=lambda: int(datetime.now().timestamp()),
description="最后更新时间戳"
)
version: int = Field(1, description="版本号")
# ==================== 默认设置 ====================
DEFAULT_SYSTEM_SETTINGS = SystemSettings()
if __name__ == '__main__':
import json
print(json.dumps(DEFAULT_SYSTEM_SETTINGS.dict(), indent=2, ensure_ascii=False))

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

@@ -3,9 +3,7 @@
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
"""
from .prompt_assembler import PromptAssembler, PromptConfig
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
# 需要使用时请显式导入例如from services.preset_service import PresetService
__all__ = [
'PromptAssembler',
'PromptConfig',
]
__all__ = []

View File

@@ -50,6 +50,7 @@ class CharacterCardConverter:
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=[], # ST没有categories
tableHeaders=[], # ST没有tableHeaders
worldInfoId=extensions.get('world'),
outputSchema=None, # ST不支持结构化输出
avatarPath=avatar_path,

View File

@@ -95,11 +95,15 @@ class CharacterService:
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=data.get('categories', []),
tags=data.get('tags', []), # ✅ 使用标签数组
worldInfoId=data.get('worldInfoId'),
outputSchema=data.get('outputSchema'),
avatarPath=avatar_path,
alternate_greetings=data.get('alternate_greetings', []),
tags=data.get('tags', []),
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,
@@ -177,8 +181,8 @@ class CharacterService:
更新角色卡
Args:
name: 角色名
updates: 更新的字段
name: 角色名(旧名称,用于定位文件夹)
updates: 更新的字段(可以包含 name 字段来重命名)
Returns:
更新后的 CharacterCard 对象
@@ -193,6 +197,29 @@ class CharacterService:
with open(char_file, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
# 检查是否需要重命名
new_name = updates.get('name')
needs_rename = new_name and new_name != name
if needs_rename:
# 验证新名称是否合法
if not new_name or new_name.strip() == '':
raise ValueError("角色名不能为空")
# 检查新名称是否已存在
new_folder = self.characters_dir / new_name
if new_folder.exists():
raise FileExistsError(f"角色 '{new_name}' 已存在")
# 重命名文件夹
try:
import shutil
shutil.move(str(char_folder), str(new_folder))
char_folder = new_folder
char_file = char_folder / "character.json"
except Exception as e:
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
# 合并更新
existing_data.update(updates)
existing_data['updatedAt'] = int(datetime.now().timestamp())

View File

@@ -10,6 +10,8 @@ import logging
from datetime import datetime
import uuid
from core.config import settings
logger = logging.getLogger(__name__)
@@ -57,7 +59,7 @@ class ChatService:
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
continue
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
return result
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
"""
@@ -156,6 +158,7 @@ class ChatService:
messages.append(msg_data)
return {
"header": header, # 完整的 header包含 tableHeaders, tableDefaults, tableData
"metadata": {
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
@@ -169,6 +172,41 @@ class ChatService:
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
raise
def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
"""
获取指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
Returns:
Dict: 消息数据,如果不存在则返回 None
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
return None
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行floor + 1因为第0行是header
message_line_index = floor + 1
if message_line_index >= len(lines):
return None
# 解析并返回消息
msg_data = json.loads(lines[message_line_index])
return msg_data
except Exception as e:
logger.error(f"获取消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
return None
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
"""
创建新聊天
@@ -192,6 +230,21 @@ class ChatService:
# 创建角色目录
chat_file.parent.mkdir(parents=True, exist_ok=True)
# 尝试从角色卡获取 tags关键字列表
tags = []
try:
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
if character_file.exists():
with open(character_file, 'r', encoding='utf-8') as f:
character_data = json.load(f)
tags = character_data.get('tags', [])
logger.info(f"从角色卡 {role_name} 继承标签: {tags}")
except Exception as e:
logger.warning(f"读取角色卡失败,使用空标签: {e}")
# 构建header
header = {
"user_name": metadata.get("user_name", "User") if metadata else "User",
@@ -207,7 +260,8 @@ class ChatService:
"timedWorldInfo": {},
"variables": {},
"tainted": False,
"lastInContextMessageId": -1
"lastInContextMessageId": -1,
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
}
# 写入header
@@ -381,3 +435,225 @@ class ChatService:
except Exception as e:
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
raise
def update_table_data(self, role_name: str, chat_name: str, table_update: Dict) -> Dict:
"""
更新标签数据SillyTavern 关键字机制)
Args:
role_name: 角色名称
chat_name: 聊天名称
table_update: 包含 tags 数组的字典
Returns:
Dict: 更新后的标签数据
Raises:
FileNotFoundError: 聊天文件不存在
"""
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}")
# 读取 header
header = json.loads(lines[0])
# 获取新的标签数组
new_tags = table_update.get('tags', [])
# 更新 header 中的 tags
header['tags'] = new_tags
# 写回文件
lines[0] = json.dumps(header, ensure_ascii=False) + '\n'
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"标签数据已更新: {role_name}/{chat_name}, 标签数: {len(new_tags)}")
return {
"success": True,
"tags": new_tags,
"tagCount": len(new_tags)
}
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
def create_branch(
self,
role_name: str,
chat_name: str,
target_floor: int,
new_chat_name: Optional[str] = None
) -> Dict:
"""
创建聊天分支
复制目标楼层及之前的所有内容到一个新的聊天记录
Args:
role_name: 角色名称
chat_name: 原聊天名称
target_floor: 目标楼层(包含该楼层及之前的内容)
new_chat_name: 新聊天名称(可选,默认自动生成)
Returns:
Dict: {
"success": bool,
"new_chat_name": str,
"message_count": int
}
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
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}")
# 验证目标楼层
# floor + 1 是因为第0行是header
message_line_index = target_floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
# 生成新聊天名称
if not new_chat_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_chat_name = f"branch_{chat_name}_{timestamp}"
# 创建新聊天文件
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
if new_chat_file.exists():
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
# 复制 header 和目标楼层及之前的消息
branch_lines = [lines[0]] # header
for i in range(1, message_line_index + 1):
msg_data = json.loads(lines[i])
# 重新分配 floor从0开始
msg_data["floor"] = i - 1
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
# 写入新文件
with open(new_chat_file, 'w', encoding='utf-8') as f:
f.writelines(branch_lines)
message_count = len(branch_lines) - 1 # 减去header
logger.info(
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
f"楼层: 0-{target_floor}, 消息数: {message_count}"
)
return {
"success": True,
"new_chat_name": new_chat_name,
"message_count": message_count,
"branched_from": chat_name,
"target_floor": target_floor
}
except (FileExistsError, ValueError):
raise
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()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
"""
图片元数据服务
负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层
数据持久化到 data/image_metadata 目录
"""
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
try:
from backend.models.internal import ImageMetadata
from backend.core.config import settings
except ImportError:
from models.internal import ImageMetadata
from core.config import settings
class ImageMetadataService:
"""
图片元数据服务
功能:
- 记录生成图片的元数据
- 按角色/聊天/楼层组织
- 支持 swipe同一楼层多张图片
- 提供画廊查询接口
"""
def __init__(self):
self.metadata_dir = settings.DATA_PATH / "image_metadata"
self.metadata_dir.mkdir(parents=True, exist_ok=True)
# 图片存储目录
self.images_dir = settings.DATA_PATH / "images"
self.images_dir.mkdir(parents=True, exist_ok=True)
def _get_chat_metadata_file(self, chat_id: str) -> Path:
"""获取指定聊天的元数据文件路径"""
# chat_id 格式: role_name/chat_name
parts = chat_id.split("/")
if len(parts) == 2:
role_name, chat_name = parts
role_dir = self.metadata_dir / role_name
role_dir.mkdir(parents=True, exist_ok=True)
return role_dir / f"{chat_name}.json"
else:
# fallback
return self.metadata_dir / f"{chat_id.replace('/', '_')}.json"
def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]:
"""加载指定聊天的所有图片元数据"""
file_path = self._get_chat_metadata_file(chat_id)
if not file_path.exists():
return []
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return [ImageMetadata(**item) for item in data]
except Exception as e:
print(f"[ImageMetadata] 加载元数据失败: {e}")
return []
def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]):
"""保存聊天的所有图片元数据"""
file_path = self._get_chat_metadata_file(chat_id)
try:
data = [m.model_dump() for m in metadata_list]
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[ImageMetadata] 保存元数据失败: {e}")
async def add_image(
self,
chat_id: str,
role_name: str,
chat_name: str,
floor: int,
filename: str,
filepath: str,
prompt: Optional[str] = None,
negative_prompt: Optional[str] = None,
seed: Optional[int] = None,
model: Optional[str] = None,
workflow_name: Optional[str] = None,
task_id: Optional[str] = None,
generation_time: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
file_size: Optional[int] = None
) -> ImageMetadata:
"""
添加图片元数据
Args:
chat_id: 聊天ID
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
filename: 文件名
filepath: 文件相对路径
prompt: 提示词
negative_prompt: 负面提示词
seed: 随机种子
model: 使用的模型
workflow_name: 工作流名称
task_id: 任务ID
generation_time: 生成耗时
width: 图片宽度
height: 图片高度
file_size: 文件大小
Returns:
ImageMetadata: 创建的元数据
"""
metadata_list = self._load_chat_metadata(chat_id)
# 计算 swipe_index
same_floor_images = [m for m in metadata_list if m.floor == floor]
swipe_index = len(same_floor_images)
# 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False
if swipe_index == 0:
for m in metadata_list:
if m.floor == floor:
m.isCurrentSwipe = False
metadata = ImageMetadata(
id=str(uuid.uuid4()),
chatId=chat_id,
roleName=role_name,
chatName=chat_name,
floor=floor,
filename=filename,
filepath=filepath,
prompt=prompt,
negativePrompt=negative_prompt,
seed=seed,
model=model,
workflowName=workflow_name,
taskId=task_id,
generationTime=generation_time,
width=width,
height=height,
fileSize=file_size,
swipeIndex=swipe_index,
isCurrentSwipe=True
)
metadata_list.append(metadata)
self._save_chat_metadata(chat_id, metadata_list)
return metadata
async def get_images_by_chat(
self,
chat_id: str,
floor: Optional[int] = None
) -> List[ImageMetadata]:
"""
获取指定聊天的图片列表
Args:
chat_id: 聊天ID
floor: 楼层号(可选,用于过滤)
Returns:
图片元数据列表
"""
metadata_list = self._load_chat_metadata(chat_id)
if floor is not None:
metadata_list = [m for m in metadata_list if m.floor == floor]
# 按楼层和 swipe_index 排序
metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex))
return metadata_list
async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]:
"""获取指定角色的所有图片"""
all_images = []
role_dir = self.metadata_dir / role_name
if not role_dir.exists():
return []
for chat_file in role_dir.glob("*.json"):
chat_name = chat_file.stem
chat_id = f"{role_name}/{chat_name}"
images = self._load_chat_metadata(chat_id)
all_images.extend(images)
# 按创建时间排序
all_images.sort(key=lambda m: m.createdAt, reverse=True)
return all_images
async def delete_image(self, chat_id: str, image_id: str) -> bool:
"""
删除图片元数据(不删除实际文件)
Args:
chat_id: 聊天ID
image_id: 图片ID
Returns:
bool: 是否成功删除
"""
metadata_list = self._load_chat_metadata(chat_id)
# 找到要删除的图片
target_image = None
for m in metadata_list:
if m.id == image_id:
target_image = m
break
if not target_image:
return False
floor = target_image.floor
swipe_index = target_image.swipeIndex
# 删除该图片
metadata_list = [m for m in metadata_list if m.id != image_id]
# 重新调整同一楼层其他图片的 swipe_index
same_floor_images = [m for m in metadata_list if m.floor == floor]
same_floor_images.sort(key=lambda m: m.swipeIndex)
for idx, m in enumerate(same_floor_images):
m.swipeIndex = idx
m.isCurrentSwipe = (idx == 0) # 第一个为当前显示
self._save_chat_metadata(chat_id, metadata_list)
return True
async def clear_chat_images(self, chat_id: str) -> int:
"""
清空指定聊天的所有图片元数据
Args:
chat_id: 聊天ID
Returns:
int: 删除的图片数量
"""
metadata_list = self._load_chat_metadata(chat_id)
count = len(metadata_list)
# 清空元数据文件
file_path = self._get_chat_metadata_file(chat_id)
if file_path.exists():
file_path.unlink()
return count
async def set_current_swipe(self, chat_id: str, image_id: str) -> bool:
"""
设置某张图片为当前显示的 swipe
Args:
chat_id: 聊天ID
image_id: 图片ID
Returns:
bool: 是否成功设置
"""
metadata_list = self._load_chat_metadata(chat_id)
target_image = None
for m in metadata_list:
if m.id == image_id:
target_image = m
break
if not target_image:
return False
floor = target_image.floor
# 将同一楼层的所有图片设为非当前
for m in metadata_list:
if m.floor == floor:
m.isCurrentSwipe = False
# 设置目标图片为当前
target_image.isCurrentSwipe = True
self._save_chat_metadata(chat_id, metadata_list)
return True
async def get_gallery_stats(self) -> Dict[str, Any]:
"""
获取画廊统计信息
Returns:
统计信息字典
"""
stats = {
"totalImages": 0,
"byRole": {},
"byChat": {}
}
if not self.metadata_dir.exists():
return stats
for role_dir in self.metadata_dir.iterdir():
if not role_dir.is_dir():
continue
role_name = role_dir.name
role_count = 0
for chat_file in role_dir.glob("*.json"):
chat_name = chat_file.stem
chat_id = f"{role_name}/{chat_name}"
images = self._load_chat_metadata(chat_id)
chat_count = len(images)
role_count += chat_count
stats["totalImages"] += chat_count
if chat_count > 0:
stats["byChat"][chat_id] = chat_count
if role_count > 0:
stats["byRole"][role_name] = role_count
return stats
def get_image_full_path(self, filepath: str) -> Path:
"""获取图片的完整路径"""
return self.images_dir / filepath
# 全局实例
image_metadata_service = ImageMetadataService()

View File

@@ -0,0 +1,281 @@
"""
JavaScript 沙盒执行引擎 + 提示词模板系统
基于 iframe 隔离的 JavaScript 代码执行环境,提供安全的脚本执行能力。
遵循 SillyTavern Tavern Helper 的设计理念。
安全特性:
- 使用 iframe 沙盒隔离执行环境
- 禁止访问 window.parent、window.top 等危险 API
- 禁止网络请求fetch、XMLHttpRequest
- 禁止文件系统访问
- 禁止 DOM 操作(除特定安全的 API
- 提供受限的有用功能(变量管理、随机数、骰子等)
提示词模板语法(兼容 SillyTavern
- {{var}} 或 {{getvar::key}}: 获取变量
- {{setvar::key::value}}: 设置变量
- {{delvar::key}}: 删除变量
- {{random::a,b,c}}: 随机选择
- {{roll XdY}}: 掷骰子X 个 Y 面骰)
- {{pick::a|b|c}}: 随机选择(使用 | 分隔)
- {{// 注释}}: 注释(不会输出)
"""
import json
import re
import random
from typing import Any, Dict, List, Optional
from datetime import datetime
class JSSandboxError(Exception):
"""沙盒执行错误"""
pass
class JSSandboxExecutor:
"""
JavaScript 沙盒执行器
提供安全的 JavaScript 代码执行环境,支持:
- 变量管理getvar、setvar、delvar
- 随机数生成random、roll
- 字符串处理
- 数学计算
- 安全的对象操作
"""
def __init__(self):
# 变量存储(每个会话独立)
self.variables: Dict[str, Any] = {}
# 禁止的危险 API 列表
self.dangerous_apis = [
'fetch', 'XMLHttpRequest', 'WebSocket',
'window.parent', 'window.top', 'window.opener',
'document.cookie', 'document.write', 'document.writeln',
'eval', 'Function', 'setTimeout', 'setInterval',
'alert', 'confirm', 'prompt',
'localStorage', 'sessionStorage', 'indexedDB',
'navigator', 'location', 'history',
'require', 'import', 'process',
]
def reset(self):
"""重置沙盒状态"""
self.variables.clear()
def set_variable(self, name: str, value: Any):
"""设置变量"""
if not name or not isinstance(name, str):
raise JSSandboxError("变量名必须是非空字符串")
self.variables[name] = value
def get_variable(self, name: str, default: Any = None) -> Any:
"""获取变量"""
return self.variables.get(name, default)
def delete_variable(self, name: str):
"""删除变量"""
if name in self.variables:
del self.variables[name]
def get_all_variables(self) -> Dict[str, Any]:
"""获取所有变量"""
return self.variables.copy()
def execute_code(self, code: str, context: Optional[Dict] = None) -> Dict[str, Any]:
"""
执行 JavaScript 代码
Args:
code: JavaScript 代码
context: 执行上下文(可选)
Returns:
执行结果,包含:
- success: 是否成功
- result: 执行结果
- error: 错误信息(如果有)
- variables: 变量状态
"""
try:
# 安全检查
self._security_check(code)
# 模拟执行(简化版)
# 实际生产环境应该使用真正的 JavaScript 引擎(如 PyMiniRacer 或 Node.js
result = self._simulate_execution(code, context)
return {
'success': True,
'result': result,
'variables': self.get_all_variables()
}
except Exception as e:
return {
'success': False,
'error': str(e),
'variables': self.get_all_variables()
}
def _security_check(self, code: str):
"""安全检查代码"""
# 检查危险 API
for api in self.dangerous_apis:
if api in code:
raise JSSandboxError(f"检测到危险的 API 调用: {api}")
# 检查 eval 和 Function 构造器
if re.search(r'\beval\s*\(', code):
raise JSSandboxError("禁止使用 eval()")
if re.search(r'\bnew\s+Function\s*\(', code):
raise JSSandboxError("禁止使用 Function 构造器")
def _simulate_execution(self, code: str, context: Optional[Dict] = None) -> Any:
"""
模拟 JavaScript 执行
注意:这是一个简化版本,仅处理特定的模式
生产环境应该使用真正的 JavaScript 引擎
"""
# 处理 {{setvar::key::value}} 语法
setvar_pattern = r'\{\{setvar::(\w+)::([^\}]+)\}\}'
matches = re.findall(setvar_pattern, code)
for key, value in matches:
self.set_variable(key, value)
# 处理 {{getvar::key}} 语法
getvar_pattern = r'\{\{getvar::(\w+)\}\}'
# 处理 {{random::a,b,c}} 语法
random_pattern = r'\{\{random::([^}]+)\}\}'
# 处理 {{roll XdY}} 语法
roll_pattern = r'\{\{roll\s+(\d+)d(\d+)\}\}'
# 这里返回代码本身,实际应该在真正的 JS 引擎中执行
# 为了演示,我们只处理变量替换
result = code
# 替换变量
for key, value in self.variables.items():
result = result.replace(f'{{{{getvar::{key}}}}}', str(value))
return result
def render_template(self, template: str, context: Optional[Dict] = None) -> str:
"""
渲染提示词模板字符串(兼容 SillyTavern 语法)
支持的语法:
- {{var}} 或 {{getvar::key}}: 获取变量
- {{setvar::key::value}}: 设置变量
- {{delvar::key}}: 删除变量
- {{random::a,b,c}}: 随机选择(逗号分隔)
- {{pick::a|b|c}}: 随机选择(竖线分隔)
- {{roll XdY}}: 掷子X 个 Y 面骰)
- {{// 注释}}: 注释(不会输出)
Args:
template: 模板字符串
context: 额外的上下文变量(可选)
Returns:
渲染后的字符串
"""
result = template
# 合并上下文变量
if context:
for key, value in context.items():
self.set_variable(key, value)
# 1. 处理 {{// 注释}} - 移除注释
result = re.sub(r'\{\{//[^}]*\}\}', '', result)
# 2. 处理 {{delvar::key}} - 删除变量
def replace_delvar(match):
key = match.group(1)
self.delete_variable(key)
return ''
result = re.sub(r'\{\{delvar::(\w+)\}\}', replace_delvar, result)
# 3. 处理 {{setvar::key::value}} - 设置变量(先设置)
def replace_setvar(match):
key, value = match.group(1), match.group(2)
self.set_variable(key, value)
return ''
result = re.sub(r'\{\{setvar::(\w+)::([^}]+)\}\}', replace_setvar, result)
# 4. 处理 {{random::a,b,c}} - 随机选择(逗号分隔)
def replace_random_comma(match):
options = match.group(1).split(',')
return random.choice([opt.strip() for opt in options if opt.strip()])
result = re.sub(r'\{\{random::([^}]+)\}\}', replace_random_comma, result)
# 5. 处理 {{pick::a|b|c}} - 随机选择(竖线分隔)
def replace_pick(match):
options = match.group(1).split('|')
return random.choice([opt.strip() for opt in options if opt.strip()])
result = re.sub(r'\{\{pick::([^}]+)\}\}', replace_pick, result)
# 6. 处理 {{roll XdY}} - 掷骰子
def replace_roll(match):
count = int(match.group(1))
sides = int(match.group(2))
rolls = [random.randint(1, sides) for _ in range(count)]
return str(sum(rolls))
result = re.sub(r'\{\{roll\s+(\d+)d(\d+)\}\}', replace_roll, result)
# 7. 处理 {{getvar::key}} - 获取变量(后获取)
def replace_getvar(match):
key = match.group(1)
return str(self.get_variable(key, ''))
result = re.sub(r'\{\{getvar::(\w+)\}\}', replace_getvar, result)
# 8. 处理 {{var}} - 获取变量(简化语法)
def replace_var(match):
key = match.group(1)
return str(self.get_variable(key, ''))
result = re.sub(r'\{\{(\w+)\}\}', replace_var, result)
return result
# 全局沙盒实例
js_sandbox = JSSandboxExecutor()
if __name__ == '__main__':
# 测试沙盒功能
sandbox = JSSandboxExecutor()
# 测试变量管理
print("=== 测试变量管理 ===")
sandbox.set_variable('test_var', 'Hello World')
print(f"获取变量: {sandbox.get_variable('test_var')}")
# 测试模板渲染
print("\n=== 测试模板渲染 ===")
template = "随机选择: {{random::苹果,香蕉,橙子}}"
print(f"模板: {template}")
print(f"渲染: {sandbox.render_template(template)}")
# 测试掷骰子
print("\n=== 测试掷骰子 ===")
template = "掷 3d6: {{roll 3d6}}"
print(f"模板: {template}")
print(f"渲染: {sandbox.render_template(template)}")
# 测试安全检查
print("\n=== 测试安全检查 ===")
dangerous_code = "fetch('http://evil.com')"
try:
sandbox.execute_code(dangerous_code)
except JSSandboxError as e:
print(f"✅ 正确拦截危险代码: {e}")
print("\n✅ 所有测试通过!")

View File

@@ -27,10 +27,21 @@ class LLMModelService:
if not base_url:
base_url = "https://api.openai.com/v1"
# 确保 base_url 以 /v1 结尾
if not base_url.endswith('/v1'):
base_url = base_url.rstrip('/') + '/v1'
# 规范化 base_url:确保有协议前缀
base_url = base_url.strip()
if not base_url.startswith(('http://', 'https://')):
base_url = 'https://' + base_url
# 移除末尾的斜杠和常见 endpoint 路径
base_url = base_url.rstrip('/')
# 移除可能已经存在的 endpoint 路径
for endpoint in ['/chat/completions', '/completions', '/embeddings', '/models']:
if base_url.endswith(endpoint):
base_url = base_url[:-len(endpoint)]
break
# 调用 models API
try:
response = requests.get(
f"{base_url}/models",
headers={
@@ -39,6 +50,12 @@ class LLMModelService:
},
timeout=10
)
except requests.exceptions.InvalidSchema as e:
raise Exception(f"URL 格式错误: {base_url}/models - 请确保 URL 以 http:// 或 https:// 开头")
except requests.exceptions.ConnectionError as e:
raise Exception(f"无法连接到 API: {base_url}/models - 请检查网络连接和 API 地址")
except requests.exceptions.Timeout as e:
raise Exception(f"请求超时: {base_url}/models - 请检查网络连接")
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
@@ -46,14 +63,8 @@ class LLMModelService:
data = response.json()
models = [model['id'] for model in data.get('data', [])]
# 过滤出聊天模型(可选)
chat_models = [
m for m in models
if any(keyword in m.lower() for keyword in ['gpt', 'chat'])
]
# 如果没有找到聊天模型,返回所有模型
return chat_models if chat_models else models
# 返回所有模型,不做过滤
return models
except Exception as e:
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
@@ -132,6 +143,9 @@ class LLMModelService:
return 'anthropic'
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
return 'ollama'
elif 'bigmodel' in api_url_lower or 'glm' in api_url_lower:
# 智谱AI GLM - 兼容 OpenAI API
return 'openai'
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
# SiliconFlow 等兼容 OpenAI API 的服务
return 'openai'

View File

@@ -0,0 +1,329 @@
"""
Preset Service
预设服务层 - 处理预设的 CRUD 操作
"""
import json
import os
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from core.config import settings
class PresetService:
"""预设服务类"""
@staticmethod
def _extract_preset_name_from_filename(filename: str) -> str:
"""
从文件名提取预设名称,去掉时间戳和文件后缀
Args:
filename: 文件名(不含路径)
Returns:
清理后的预设名称
Examples:
"Default.json" -> "Default"
"MyPreset_1234567890.json" -> "MyPreset"
"Test_1714567890123.json" -> "Test"
"""
# 去掉 .json 后缀
name = filename.replace('.json', '')
# 去掉末尾的时间戳(下划线+数字组合)
# 匹配模式_后面跟着10-13位数字Unix时间戳
import re
name = re.sub(r'_\d{10,13}$', '', name)
return name
@staticmethod
def _get_preset_path(name: str) -> Path:
"""获取预设文件路径"""
return settings.PRESET_PATH / f"{name}.json"
@staticmethod
def _load_preset(name: str) -> Optional[Dict[str, Any]]:
"""加载预设 JSON 文件"""
path = PresetService._get_preset_path(name)
if not path.exists():
return None
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
raise ValueError(f"Failed to load preset '{name}': {str(e)}")
@staticmethod
def _save_preset(name: str, data: Dict[str, Any]):
"""保存预设到 JSON 文件"""
path = PresetService._get_preset_path(name)
try:
# 确保 prompts 数组和 prompt_order 的顺序一致
if "prompts" in data and "prompt_order" in data:
prompts = data["prompts"]
prompt_order = data.get("prompt_order", [{}])[0].get("order", [])
if prompts and prompt_order:
# 创建 identifier 到 prompt 的映射
prompt_map = {prompt["identifier"]: prompt for prompt in prompts}
# 按照 prompt_order 的顺序重新排列 prompts
reordered_prompts = []
for order_item in prompt_order:
identifier = order_item.get("identifier")
if identifier and identifier in prompt_map:
reordered_prompts.append(prompt_map[identifier])
# 添加 prompt_order 中不存在的 prompts如果有
existing_identifiers = {item.get("identifier") for item in prompt_order}
for prompt in prompts:
if prompt["identifier"] not in existing_identifiers:
reordered_prompts.append(prompt)
data["prompts"] = reordered_prompts
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
raise ValueError(f"Failed to save preset '{name}': {str(e)}")
@staticmethod
def list_presets() -> List[Dict[str, Any]]:
"""
获取所有预设的列表(仅基本信息)
Returns:
预设列表,每个包含 name, description, component_count, temperature 等
"""
presets = []
for json_file in settings.PRESET_PATH.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 计算组件数量 - 支持 SillyTavern 格式 (prompts) 和内部格式 (entries)
prompts = data.get("prompts", [])
entries = data.get("entries", [])
component_count = len(prompts) if prompts else len(entries)
# 提取温度参数 - 使用 SillyTavern 标准字段名
temperature = data.get("temperature", 1.0)
# 从文件名提取预设名称(去掉时间戳和后缀)
preset_name = PresetService._extract_preset_name_from_filename(json_file.name)
preset_info = {
"name": preset_name,
"description": data.get("description", ""),
"component_count": component_count,
"temperature": temperature
}
presets.append(preset_info)
except Exception as e:
print(f"Error loading preset {json_file.name}: {e}")
continue
# 按名称排序
presets.sort(key=lambda x: x.get("name", ""))
return presets
@staticmethod
def get_preset(name: str) -> Dict[str, Any]:
"""
获取指定预设的完整数据
Args:
name: 预设名称
Returns:
预设完整数据
"""
data = PresetService._load_preset(name)
if not data:
raise FileNotFoundError(f"Preset '{name}' not found")
return data
@staticmethod
def create_preset(name: str, preset_data: Dict[str, Any]) -> Dict[str, Any]:
"""
创建新预设
Args:
name: 预设名称
preset_data: 预设数据
Returns:
创建的预设数据
"""
# 检查是否已存在
if PresetService._get_preset_path(name).exists():
raise ValueError(f"Preset '{name}' already exists")
# 确保有必要的字段
if "name" not in preset_data:
preset_data["name"] = name
# 添加时间戳
now = int(datetime.now().timestamp())
if "createdAt" not in preset_data:
preset_data["createdAt"] = now
if "updatedAt" not in preset_data:
preset_data["updatedAt"] = now
PresetService._save_preset(name, preset_data)
return preset_data
@staticmethod
def update_preset(name: str, update_data: Dict[str, Any]) -> Dict[str, Any]:
"""
更新预设
Args:
name: 预设名称
update_data: 要更新的数据
Returns:
更新后的预设数据
"""
data = PresetService._load_preset(name)
if not data:
raise FileNotFoundError(f"Preset '{name}' not found")
# 更新字段
for key, value in update_data.items():
if key not in ["name", "createdAt"]: # 不允许修改名称和创建时间
data[key] = value
# 更新时间戳
data["updatedAt"] = int(datetime.now().timestamp())
PresetService._save_preset(name, data)
return data
@staticmethod
def delete_preset(name: str) -> bool:
"""
删除预设
Args:
name: 预设名称
Returns:
是否删除成功
"""
path = PresetService._get_preset_path(name)
if not path.exists():
raise FileNotFoundError(f"Preset '{name}' not found")
path.unlink()
return True
@staticmethod
def rename_preset(old_name: str, new_name: str) -> Dict[str, Any]:
"""
重命名预设(同时修改文件名和内部 name 字段)
Args:
old_name: 原预设名称
new_name: 新预设名称
Returns:
更新后的预设数据
"""
# 检查原预设是否存在
old_path = PresetService._get_preset_path(old_name)
if not old_path.exists():
raise FileNotFoundError(f"Preset '{old_name}' not found")
# 检查新名称是否已存在
new_path = PresetService._get_preset_path(new_name)
if new_path.exists() and old_name != new_name:
raise ValueError(f"Preset '{new_name}' already exists")
# 加载原预设数据
data = PresetService._load_preset(old_name)
if not data:
raise FileNotFoundError(f"Preset '{old_name}' not found")
# 更新内部的 name 字段
data["name"] = new_name
# 更新时间戳
data["updatedAt"] = int(datetime.now().timestamp())
# 保存到新文件
PresetService._save_preset(new_name, data)
# 删除旧文件(如果名称不同)
if old_name != new_name:
old_path.unlink()
return data
@staticmethod
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
"""
重新排序预设组件 - 支持 SillyTavern 标准格式
Args:
name: 预设名称
component_order: 组件 identifier 列表,按新顺序排列
Returns:
更新后的预设数据
"""
data = PresetService._load_preset(name)
if not data:
raise FileNotFoundError(f"Preset '{name}' not found")
# 支持 SillyTavern 格式的 prompts
if "prompts" in data and isinstance(data["prompts"], list):
# 创建 identifier 到 prompt 的映射
prompt_map = {prompt["identifier"]: prompt for prompt in data["prompts"]}
# 按新顺序重新排列
reordered_prompts = []
for identifier in component_order:
if identifier in prompt_map:
reordered_prompts.append(prompt_map[identifier])
data["prompts"] = reordered_prompts
# 更新 prompt_order
if "prompt_order" in data and isinstance(data["prompt_order"], list) and len(data["prompt_order"]) > 0:
data["prompt_order"][0]["order"] = [
{"identifier": identifier, "enabled": True}
for identifier in component_order
if identifier in prompt_map
]
# 也支持内部格式的 entries向后兼容
elif "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,361 @@
"""
正则规则服务(重构版 - 文件夹结构)
负责加载、管理和应用正则替换规则。
使用文件夹结构组织规则,兼容 SillyTavern 格式。
文件结构:
data/regex/
├── global/ # 全局规则
│ └── default.json
├── characters/ # 角色卡绑定规则
│ └── {characterName}.json
└── presets/ # 预设绑定规则
└── {presetName}.json
"""
import re
import json
import logging
from pathlib import Path
from typing import List, Optional, Dict
from uuid import uuid4
from core.config import settings
from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode
from services.system_settings_service import system_settings_service
logger = logging.getLogger(__name__)
class RegexService:
"""
正则替换规则服务(文件夹结构版)
"""
def __init__(self):
self.regex_base_path = settings.DATA_PATH / "regex"
self.global_path = self.regex_base_path / "global"
self.characters_path = self.regex_base_path / "characters"
self.presets_path = self.regex_base_path / "presets"
# 内存缓存
self.global_rulesets: Dict[str, RegexRuleset] = {}
self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName
self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName
self._ensure_directories()
self._load_all_rules()
def _ensure_directories(self):
"""确保目录结构存在"""
for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]:
path.mkdir(parents=True, exist_ok=True)
def _load_all_rules(self):
"""加载所有规则"""
self._load_global_rules()
self._load_character_rules()
self._load_preset_rules()
def _load_global_rules(self):
"""加载全局规则"""
if not self.global_path.exists():
return
for json_file in self.global_path.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
# SillyTavern 格式
ruleset = self._convert_sillytavern_format(data, json_file.stem)
elif isinstance(data, dict) and 'rules' in data:
# 我们的规则集格式
ruleset = RegexRuleset(**data)
else:
logger.warning(f"未知的规则文件格式: {json_file}")
continue
self.global_rulesets[ruleset.id] = ruleset
logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载全局规则失败 {json_file}: {e}")
def _load_character_rules(self):
"""加载角色卡绑定规则"""
if not self.characters_path.exists():
return
for json_file in self.characters_path.glob("*.json"):
try:
character_name = json_file.stem
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER)
elif isinstance(data, dict) and 'rules' in data:
ruleset = RegexRuleset(**data)
else:
continue
# 确保所有规则的 scope 正确
for rule in ruleset.rules:
rule.scope = RegexScope.CHARACTER
rule.characterName = character_name
self.character_rulesets[character_name] = ruleset
logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载角色规则失败 {json_file}: {e}")
def _load_preset_rules(self):
"""加载预设绑定规则"""
if not self.presets_path.exists():
return
for json_file in self.presets_path.glob("*.json"):
try:
preset_name = json_file.stem
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET)
elif isinstance(data, dict) and 'rules' in data:
ruleset = RegexRuleset(**data)
else:
continue
# 确保所有规则的 scope 正确
for rule in ruleset.rules:
rule.scope = RegexScope.PRESET
rule.presetName = preset_name
self.preset_rulesets[preset_name] = ruleset
logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载预设规则失败 {json_file}: {e}")
def _convert_sillytavern_format(
self,
st_rules: List[dict],
name: str,
scope: RegexScope = RegexScope.GLOBAL
) -> RegexRuleset:
"""将 SillyTavern 格式转换为内部格式"""
rules = []
for idx, st_rule in enumerate(st_rules):
find_regex = st_rule.get('findRegex', '')
pattern, flags = self._parse_st_regex(find_regex)
# 解析 placement默认为 AI_OUTPUT
placement_data = st_rule.get('placement', [2])
placement = [RegexPlacement(p) for p in placement_data]
rule = RegexRule(
id=str(uuid4()),
scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"),
findRegex=pattern,
replaceString=st_rule.get('replaceString', ''),
trimStrings=st_rule.get('trimStrings', []),
placement=placement,
substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)),
markdownOnly=st_rule.get('markdownOnly', False),
promptOnly=st_rule.get('promptOnly', False),
runOnEdit=st_rule.get('runOnEdit', True),
minDepth=st_rule.get('minDepth', 0),
maxDepth=st_rule.get('maxDepth'),
scope=scope,
characterName=name if scope == RegexScope.CHARACTER else None,
presetName=name if scope == RegexScope.PRESET else None,
disabled=st_rule.get('disabled', False),
order=idx
)
rules.append(rule)
ruleset = RegexRuleset(
id=str(uuid4()),
name=f"{name} 规则集",
description=f"从 SillyTavern 导入的规则",
rules=rules,
isSillyTavernFormat=True
)
return ruleset
def _parse_st_regex(self, st_regex: str) -> tuple[str, str]:
"""解析 SillyTavern 的正则表达式格式 /pattern/flags"""
if st_regex.startswith('/') and st_regex.count('/') >= 2:
parts = st_regex.split('/')
pattern = '/'.join(parts[1:-1])
flags = parts[-1] if len(parts) > 2 else ''
return pattern, flags
else:
return st_regex, ''
def apply_rules_by_placement(
self,
text: str,
placement: int,
character_name: Optional[str] = None,
preset_name: Optional[str] = None,
message_depth: int = 0,
is_for_llm: bool = False, # ✅ 新增是否发送给LLM
is_markdown_rendered: bool = False # ✅ 新增是否已Markdown渲染
) -> str:
"""
根据 placement 应用正则规则
Args:
text: 要处理的文本
placement: 应用位置0-5
character_name: 当前角色卡名称
preset_name: 当前预设名称
message_depth: 消息深度
is_for_llm: 是否用于发送给 LLM影响 promptOnly 逻辑)
is_markdown_rendered: 是否是 Markdown 渲染后的内容(影响 markdownOnly 逻辑)
Returns:
处理后的文本
"""
rules = self.get_rules_for_context(character_name, preset_name)
result = text
for rule in rules:
# ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用
# - 双 false应用到所有场景包括保存数据、发送LLM、显示
# - markdownOnly=true只应用于 Markdown 渲染(前端显示)
# - promptOnly=true只应用于发送给 LLM
# - 双 true应用到所有场景但不修改存储由调用方决定
# 如果是保存数据的场景is_for_llm=False 且 is_markdown_rendered=False
# 只应用双 false 的规则
if not is_for_llm and not is_markdown_rendered:
# 保存数据:只应用双 false 的规则
if rule.markdownOnly or rule.promptOnly:
continue
# 如果是发送给 LLM 的场景
elif is_for_llm and not is_markdown_rendered:
# 不应用 markdownOnly=true 且 promptOnly=false 的规则
if rule.markdownOnly and not rule.promptOnly:
continue
# 如果是 Markdown 渲染的场景(前端显示)
elif is_markdown_rendered and not is_for_llm:
# 不应用 promptOnly=true 且 markdownOnly=false 的规则
if rule.promptOnly and not rule.markdownOnly:
continue
# 检查此规则是否适用于当前 placement
if placement not in [p.value for p in rule.placement]:
continue
# 检查消息深度限制
if message_depth < rule.minDepth:
continue
if rule.maxDepth is not None and message_depth > rule.maxDepth:
continue
# 应用规则
result = self._apply_single_rule(result, rule)
return result
def _apply_single_rule(self, text: str, rule: RegexRule) -> str:
"""应用单条正则规则"""
try:
flags = 0
if 'i' in rule.findRegex:
flags |= re.IGNORECASE
if 'm' in rule.findRegex:
flags |= re.MULTILINE
if 's' in rule.findRegex:
flags |= re.DOTALL
pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '')
if rule.substituteRegex == SubstituteMode.REPLACE_FIRST:
result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags)
else:
result = re.sub(pattern, rule.replaceString, text, flags=flags)
for trim_str in rule.trimStrings:
result = result.replace(trim_str, '')
return result
except re.error as e:
logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}")
return text
def get_rules_for_context(
self,
character_name: Optional[str] = None,
preset_name: Optional[str] = None
) -> List[RegexRule]:
"""
根据上下文获取适用的规则列表
优先级:全局规则 + 角色规则 + 预设规则
"""
applicable_rules = []
# 1. 加载全局规则
for ruleset in self.global_rulesets.values():
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 2. 加载角色卡规则
if character_name and character_name in self.character_rulesets:
ruleset = self.character_rulesets[character_name]
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 3. 加载预设规则
if preset_name and preset_name in self.preset_rulesets:
ruleset = self.preset_rulesets[preset_name]
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 按 order 排序
applicable_rules.sort(key=lambda r: r.order)
return applicable_rules
def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None):
"""保存规则集到文件"""
if scope == RegexScope.GLOBAL:
file_path = self.global_path / f"{ruleset.id}.json"
elif scope == RegexScope.CHARACTER:
file_path = self.characters_path / f"{name or 'unknown'}.json"
elif scope == RegexScope.PRESET:
file_path = self.presets_path / f"{name or 'unknown'}.json"
else:
raise ValueError(f"未知的作用域: {scope}")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2)
logger.info(f"保存规则集到: {file_path}")
def delete_ruleset(self, scope: RegexScope, name: str):
"""删除规则集"""
if scope == RegexScope.CHARACTER:
file_path = self.characters_path / f"{name}.json"
elif scope == RegexScope.PRESET:
file_path = self.presets_path / f"{name}.json"
else:
raise ValueError(f"不能删除全局规则集")
if file_path.exists():
file_path.unlink()
logger.info(f"删除规则集: {file_path}")
# 全局实例
regex_service = RegexService()

View File

@@ -0,0 +1,189 @@
"""
脚本管理模块
管理 Tavern Helper 的脚本,支持三种作用域:
- GLOBAL: 全局脚本,对所有聊天可用
- CHARACTER: 角色脚本,绑定到当前角色卡
- PRESET: 预设脚本,绑定到当前预设
每个脚本包含:
- 脚本名称
- 脚本内容JavaScript 代码)
- 作者备注
- 变量列表(绑定到脚本的变量)
- 按钮配置(配合 getButtonEvent 使用)
- 启用状态
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
import uuid
class ScriptScope(str, Enum):
"""脚本作用域"""
GLOBAL = 'global' # 全局脚本
CHARACTER = 'character' # 角色脚本
PRESET = 'preset' # 预设脚本
class ScriptVariable(BaseModel):
"""脚本变量"""
name: str = Field(..., description="变量名")
value: Any = Field(..., description="变量值")
description: Optional[str] = Field(None, description="变量描述")
class ScriptButton(BaseModel):
"""脚本按钮配置"""
label: str = Field(..., description="按钮显示文本")
event: str = Field(..., description="按钮事件名称(配合 getButtonEvent 使用)")
enabled: bool = Field(True, description="是否启用")
class ScriptItem(BaseModel):
"""脚本项"""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="脚本唯一标识符")
name: str = Field(..., description="脚本名称")
content: str = Field(..., description="脚本内容JavaScript 代码)")
authorNote: Optional[str] = Field(None, description="作者备注")
# 变量列表
variables: List[ScriptVariable] = Field(default_factory=list, description="绑定到脚本的变量")
# 按钮配置
buttons: List[ScriptButton] = Field(default_factory=list, description="按钮配置")
# 作用域
scope: ScriptScope = Field(ScriptScope.GLOBAL, description="脚本作用域")
characterName: Optional[str] = Field(None, description="绑定的角色卡名称")
presetName: Optional[str] = Field(None, description="绑定的预设名称")
# 启用状态
enabled: bool = Field(True, description="是否启用")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="更新时间戳")
order: int = Field(0, description="执行顺序")
class ScriptManager:
"""脚本管理器"""
def __init__(self):
self.scripts: List[ScriptItem] = []
def add_script(self, script: ScriptItem):
"""添加脚本"""
self.scripts.append(script)
def remove_script(self, script_id: str) -> bool:
"""删除脚本"""
for i, script in enumerate(self.scripts):
if script.id == script_id:
self.scripts.pop(i)
return True
return False
def update_script(self, script_id: str, updates: Dict[str, Any]) -> bool:
"""更新脚本"""
for script in self.scripts:
if script.id == script_id:
for key, value in updates.items():
if hasattr(script, key):
setattr(script, key, value)
script.updatedAt = int(datetime.now().timestamp())
return True
return False
def get_scripts_by_scope(self, scope: ScriptScope, filter_name: Optional[str] = None) -> List[ScriptItem]:
"""按作用域获取脚本"""
scripts = [s for s in self.scripts if s.scope == scope]
if filter_name:
scripts = [s for s in scripts if filter_name.lower() in s.name.lower()]
return sorted(scripts, key=lambda s: s.order)
def get_enabled_scripts(self, scope: ScriptScope) -> List[ScriptItem]:
"""获取启用的脚本"""
return [s for s in self.scripts if s.scope == scope and s.enabled]
def get_script(self, script_id: str) -> Optional[ScriptItem]:
"""获取单个脚本"""
for script in self.scripts:
if script.id == script_id:
return script
return None
def toggle_script(self, script_id: str) -> bool:
"""切换脚本启用状态"""
for script in self.scripts:
if script.id == script_id:
script.enabled = not script.enabled
script.updatedAt = int(datetime.now().timestamp())
return True
return False
def get_all_scripts(self) -> List[ScriptItem]:
"""获取所有脚本"""
return self.scripts
def export_scripts(self, scope: Optional[ScriptScope] = None) -> List[Dict]:
"""导出脚本"""
if scope:
scripts = [s for s in self.scripts if s.scope == scope]
else:
scripts = self.scripts
return [s.dict() for s in scripts]
def import_scripts(self, scripts_data: List[Dict], scope: ScriptScope) -> int:
"""导入脚本"""
count = 0
for data in scripts_data:
try:
script = ScriptItem(**data)
script.scope = scope
self.scripts.append(script)
count += 1
except Exception as e:
print(f"导入脚本失败: {e}")
return count
# 全局脚本管理器实例
script_manager = ScriptManager()
if __name__ == '__main__':
import json
# 测试脚本管理
manager = ScriptManager()
# 添加测试脚本
script1 = ScriptItem(
name="【骰子系统】-自动更新",
content="async function getLatestVersion() {\n try {\n const response = await fetch('/api/version');\n return await response.json();\n } catch (e) {\n return null;\n }\n}",
authorNote="感谢a佬开源\n以九颜二改为基础进行三改\n@kousakayou",
scope=ScriptScope.GLOBAL,
variables=[
ScriptVariable(name="version", value="4.8.4", description="版本号")
],
buttons=[
ScriptButton(label="检查更新", event="checkUpdate", enabled=True)
]
)
manager.add_script(script1)
# 导出测试
print("=== 导出脚本 ===")
exported = manager.export_scripts()
print(json.dumps(exported, indent=2, ensure_ascii=False))
print("\n✅ 脚本管理测试完成!")

View File

@@ -0,0 +1,95 @@
"""
系统设置服务
负责加载、保存和管理全局系统设置。
"""
import json
import logging
from pathlib import Path
from typing import Optional
from core.config import settings
from models.system_settings import SystemSettings, DEFAULT_SYSTEM_SETTINGS
logger = logging.getLogger(__name__)
class SystemSettingsService:
"""
系统设置服务
提供设置的加载、保存和访问功能
"""
def __init__(self):
self.settings_file = settings.SYSTEM_SETTINGS_FILE
self._settings: Optional[SystemSettings] = None
self._load_settings()
def _load_settings(self):
"""从文件加载系统设置"""
if self.settings_file.exists():
try:
with open(self.settings_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._settings = SystemSettings(**data)
logger.info(f"加载系统设置成功")
except Exception as e:
logger.error(f"加载系统设置失败: {e}")
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
else:
logger.info("系统设置文件不存在,使用默认设置")
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
self._save_settings()
def _save_settings(self):
"""保存系统设置到文件"""
try:
# 确保父目录存在
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self._settings.dict(), f, ensure_ascii=False, indent=2)
logger.info(f"系统设置已保存到 {self.settings_file}")
except Exception as e:
logger.error(f"保存系统设置失败: {e}")
@property
def settings(self) -> SystemSettings:
"""获取当前系统设置"""
return self._settings
def update_thinking_tags(self, prefix: str, suffix: str):
"""更新思考标签配置"""
self._settings.thinkingTagPrefix = prefix
self._settings.thinkingTagSuffix = suffix
self._settings.updatedAt = int(__import__('time').time())
self._save_settings()
logger.info(f"思考标签已更新: {prefix} ... {suffix}")
def update_current_preset(self, preset_name: Optional[str]):
"""更新当前选中的预设名称"""
self._settings.currentPresetName = preset_name
self._settings.updatedAt = int(__import__('time').time())
self._save_settings()
logger.info(f"当前预设已更新: {preset_name}")
def get_thinking_tag_pattern(self) -> str:
"""获取思考标签的正则表达式模式"""
prefix = self._settings.thinkingTagPrefix
suffix = self._settings.thinkingTagSuffix
# 转义特殊字符
import re
escaped_prefix = re.escape(prefix)
escaped_suffix = re.escape(suffix)
# 返回匹配思考内容的正则模式
return f"{escaped_prefix}[\\s\\S]*?{escaped_suffix}"
# 全局实例
system_settings_service = SystemSettingsService()

View File

@@ -0,0 +1,161 @@
"""
任务队列管理器
管理并行任务(生图、动态表格维护等)的状态和生命周期
"""
import asyncio
from typing import Dict, List, Optional
from enum import Enum
from datetime import datetime
class TaskStatus(Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待中
RUNNING = "running" # 进行中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 已取消
class TaskType(Enum):
"""任务类型枚举"""
IMAGE_WORKFLOW = "image_workflow"
DYNAMIC_TABLE = "dynamic_table"
class TaskItem:
"""任务项"""
def __init__(self, task_id: str, task_type: TaskType, chat_id: str):
self.task_id = task_id
self.task_type = task_type
self.chat_id = chat_id
self.status = TaskStatus.PENDING
self.created_at = datetime.now()
self.started_at = None
self.completed_at = None
self.error = None
self.metadata = {} # 用于存储提示词、修改内容等
def to_dict(self):
"""转换为字典格式(前端友好)"""
return {
"taskId": self.task_id,
"taskType": self.task_type.value,
"chatId": self.chat_id,
"status": self.status.value,
"createdAt": self.created_at.isoformat(),
"startedAt": self.started_at.isoformat() if self.started_at else None,
"completedAt": self.completed_at.isoformat() if self.completed_at else None,
"error": self.error,
"metadata": self.metadata
}
class TaskQueueManager:
"""
全局任务队列管理器
功能:
- 管理所有并行任务的生命周期
- 支持按聊天ID查询任务
- 支持取消任务
- 自动清理已完成的任务
"""
def __init__(self):
self.tasks: Dict[str, TaskItem] = {}
self.chat_tasks: Dict[str, List[str]] = {} # chat_id -> [task_ids]
self._lock = asyncio.Lock()
async def add_task(self, task_id: str, task_type: TaskType, chat_id: str) -> TaskItem:
"""添加任务到队列"""
async with self._lock:
task = TaskItem(task_id, task_type, chat_id)
self.tasks[task_id] = task
if chat_id not in self.chat_tasks:
self.chat_tasks[chat_id] = []
self.chat_tasks[chat_id].append(task_id)
return task
async def start_task(self, task_id: str):
"""标记任务开始执行"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.RUNNING
self.tasks[task_id].started_at = datetime.now()
async def complete_task(self, task_id: str, metadata: dict = None):
"""标记任务完成"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.COMPLETED
self.tasks[task_id].completed_at = datetime.now()
if metadata:
self.tasks[task_id].metadata.update(metadata)
async def fail_task(self, task_id: str, error: str):
"""标记任务失败"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.FAILED
self.tasks[task_id].completed_at = datetime.now()
self.tasks[task_id].error = error
async def cancel_task(self, task_id: str) -> bool:
"""
取消任务
Returns:
bool: 是否成功取消
"""
async with self._lock:
if task_id in self.tasks:
task = self.tasks[task_id]
if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
task.status = TaskStatus.CANCELLED
task.completed_at = datetime.now()
return True
return False
async def get_chat_tasks(self, chat_id: str, include_completed: bool = False) -> List[dict]:
"""
获取某个聊天的所有任务
Args:
chat_id: 聊天ID
include_completed: 是否包含已完成的任务
Returns:
List[dict]: 任务列表
"""
async with self._lock:
task_ids = self.chat_tasks.get(chat_id, [])
tasks = []
for task_id in task_ids:
if task_id in self.tasks:
task = self.tasks[task_id]
# 根据参数决定是否包含已完成的任务
if include_completed or task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
tasks.append(task.to_dict())
return tasks
async def cleanup_completed_tasks(self, chat_id: str):
"""清理已完成的任务"""
async with self._lock:
if chat_id in self.chat_tasks:
task_ids = self.chat_tasks[chat_id]
completed_ids = [
tid for tid in task_ids
if tid in self.tasks and
self.tasks[tid].status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
]
for tid in completed_ids:
del self.tasks[tid]
self.chat_tasks[chat_id].remove(tid)
# 全局实例
task_queue_manager = TaskQueueManager()

View File

@@ -0,0 +1,427 @@
"""
Token 使用统计服务
负责记录、查询和分析 LLM 调用的 token 使用情况
数据持久化到 data/token_usage 目录,按月份组织
采用双层存储:
1. JSONL 文件 - 详细记录(按月存储)
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度
"""
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
from collections import defaultdict
try:
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
from backend.core.config import settings
except ImportError:
from models.internal import TokenUsageRecord, TokenUsageStatus
from core.config import settings
class TokenUsageService:
"""
Token 使用统计服务
功能:
- 记录每次 LLM 调用的 token 使用情况
- 按月份、日期、角色、聊天、API URL 维度统计
- 支持中断和失败标记
- 数据持久化到文件系统JSONL + 索引)
"""
def __init__(self):
self.token_usage_dir = settings.DATA_PATH / "token_usage"
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
# ✅ 索引文件目录 - 用于快速聚合查询
self.index_dir = self.token_usage_dir / "indexes"
self.index_dir.mkdir(parents=True, exist_ok=True)
def _get_month_file(self, year: int, month: int) -> Path:
"""获取指定月份的统计文件路径"""
month_dir = self.token_usage_dir / f"{year}"
month_dir.mkdir(parents=True, exist_ok=True)
return month_dir / f"{month:02d}.jsonl"
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
"""加载指定月份的所有记录"""
file_path = self._get_month_file(year, month)
if not file_path.exists():
return []
records = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
data = json.loads(line)
records.append(TokenUsageRecord(**data))
except Exception as e:
print(f"[TokenUsage] 加载记录失败: {e}")
return records
def _save_record(self, record: TokenUsageRecord):
"""保存单条记录到对应的月份文件"""
dt = datetime.fromtimestamp(record.timestamp)
file_path = self._get_month_file(dt.year, dt.month)
try:
with open(file_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
# ✅ 同时更新索引文件(用于快速查询)
self._update_indexes(record)
except Exception as e:
print(f"[TokenUsage] 保存记录失败: {e}")
def _update_indexes(self, record: TokenUsageRecord):
"""
更新索引文件 - 实现高效的按维度聚合查询
索引结构:
- indexes/api_urls.json - 按 API URL 聚合
- indexes/daily/{year}-{month}.json - 按日聚合
"""
dt = datetime.fromtimestamp(record.timestamp)
# 1. 更新 API URL 索引
if record.apiUrl:
api_url_index = self.index_dir / "api_urls.json"
self._update_api_url_index(api_url_index, record)
# 2. 更新每日索引
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
daily_index.parent.mkdir(parents=True, exist_ok=True)
self._update_daily_index(daily_index, record)
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
"""更新 API URL 索引文件"""
index_data = {}
# 加载现有索引
if index_file.exists():
try:
with open(index_file, 'r', encoding='utf-8') as f:
index_data = json.load(f)
except:
index_data = {}
# 更新统计
api_url = record.apiUrl
if api_url not in index_data:
index_data[api_url] = {
"totalPromptTokens": 0,
"totalCompletionTokens": 0,
"totalTokens": 0,
"count": 0,
"firstUsed": record.timestamp,
"lastUsed": record.timestamp
}
stats = index_data[api_url]
stats["totalPromptTokens"] += record.promptTokens
stats["totalCompletionTokens"] += record.completionTokens
stats["totalTokens"] += record.totalTokens
stats["count"] += 1
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
# 保存索引
with open(index_file, 'w', encoding='utf-8') as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
"""更新每日索引文件"""
dt = datetime.fromtimestamp(record.timestamp)
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
index_data = {}
# 加载现有索引
if index_file.exists():
try:
with open(index_file, 'r', encoding='utf-8') as f:
index_data = json.load(f)
except:
index_data = {}
# 更新统计
if day_key not in index_data:
index_data[day_key] = {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
}
stats = index_data[day_key]
stats["promptTokens"] += record.promptTokens
stats["completionTokens"] += record.completionTokens
stats["totalTokens"] += record.totalTokens
stats["count"] += 1
# 保存索引
with open(index_file, 'w', encoding='utf-8') as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
async def record_usage(
self,
chat_id: str,
role_name: str,
chat_name: str,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
message_id: Optional[str] = None,
floor: Optional[int] = None,
error_message: Optional[str] = None,
duration: Optional[float] = None,
model: Optional[str] = None,
api_provider: Optional[str] = None,
api_url: Optional[str] = None
) -> TokenUsageRecord:
"""
记录一次 LLM 调用的 token 使用情况
Args:
chat_id: 聊天ID
role_name: 角色名称
chat_name: 聊天名称
prompt_tokens: 输入 token 数
completion_tokens: 输出 token 数
total_tokens: 总 token 数
status: 请求状态
message_id: 关联的消息ID
floor: 楼层号
error_message: 错误信息
duration: 请求耗时
model: 使用的模型
api_provider: API 提供商
api_url: API URL地址
Returns:
TokenUsageRecord: 创建的记录
"""
record = TokenUsageRecord(
id=str(uuid.uuid4()),
chatId=chat_id,
roleName=role_name,
chatName=chat_name,
messageId=message_id,
floor=floor,
promptTokens=prompt_tokens,
completionTokens=completion_tokens,
totalTokens=total_tokens,
status=status,
errorMessage=error_message,
duration=duration,
model=model,
apiProvider=api_provider,
apiUrl=api_url
)
self._save_record(record)
return record
async def get_stats_by_month(
self,
year: int,
month: int,
role_name: Optional[str] = None,
chat_name: Optional[str] = None
) -> Dict[str, Any]:
"""
获取指定月份的统计数据
Args:
year: 年份
month: 月份
role_name: 角色名称(可选,用于过滤)
chat_name: 聊天名称(可选,用于过滤)
Returns:
统计数据字典
"""
records = self._load_month_records(year, month)
# 过滤
if role_name:
records = [r for r in records if r.roleName == role_name]
if chat_name:
records = [r for r in records if r.chatName == chat_name]
# 统计
total_prompt = sum(r.promptTokens for r in records)
total_completion = sum(r.completionTokens for r in records)
total_tokens = sum(r.totalTokens for r in records)
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
# 按日期分组
daily_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
dt = datetime.fromtimestamp(r.timestamp)
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
daily_stats[day_key]["promptTokens"] += r.promptTokens
daily_stats[day_key]["completionTokens"] += r.completionTokens
daily_stats[day_key]["totalTokens"] += r.totalTokens
daily_stats[day_key]["count"] += 1
# 按角色分组
role_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
role_stats[r.roleName]["promptTokens"] += r.promptTokens
role_stats[r.roleName]["completionTokens"] += r.completionTokens
role_stats[r.roleName]["totalTokens"] += r.totalTokens
role_stats[r.roleName]["count"] += 1
# 按聊天分组
chat_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
chat_key = f"{r.roleName}/{r.chatName}"
chat_stats[chat_key]["promptTokens"] += r.promptTokens
chat_stats[chat_key]["completionTokens"] += r.completionTokens
chat_stats[chat_key]["totalTokens"] += r.totalTokens
chat_stats[chat_key]["count"] += 1
# ✅ 按 API URL 分组
api_url_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
if r.apiUrl:
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
api_url_stats[r.apiUrl]["count"] += 1
return {
"year": year,
"month": month,
"totalRecords": len(records),
"totalPromptTokens": total_prompt,
"totalCompletionTokens": total_completion,
"totalTokens": total_tokens,
"completedCount": completed_count,
"interruptedCount": interrupted_count,
"failedCount": failed_count,
"dailyStats": dict(daily_stats),
"roleStats": dict(role_stats),
"chatStats": dict(chat_stats),
"apiUrlStats": dict(api_url_stats), # ✅ 新增
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
}
async def list_months(self) -> List[Dict[str, int]]:
"""列出所有有数据的月份"""
months = []
if not self.token_usage_dir.exists():
return months
for year_dir in sorted(self.token_usage_dir.iterdir()):
if year_dir.is_dir() and year_dir.name.isdigit():
year = int(year_dir.name)
for month_file in sorted(year_dir.glob("*.jsonl")):
month = int(month_file.stem)
months.append({"year": year, "month": month})
return months
async def get_api_url_stats(self) -> Dict[str, Any]:
"""
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
Returns:
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
"""
api_url_index = self.index_dir / "api_urls.json"
if not api_url_index.exists():
return {}
try:
with open(api_url_index, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
return {}
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
"""
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
Args:
year: 年份
month: 月份
Returns:
{day_key: {promptTokens, completionTokens, totalTokens, count}}
"""
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
if not daily_index.exists():
return {}
try:
with open(daily_index, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[TokenUsage] 读取每日索引失败: {e}")
return {}
async def get_available_roles(self, year: int, month: int) -> List[str]:
"""获取指定月份有数据的角色列表"""
records = self._load_month_records(year, month)
roles = set(r.roleName for r in records)
return sorted(list(roles))
async def get_available_chats(
self,
year: int,
month: int,
role_name: Optional[str] = None
) -> List[str]:
"""获取指定月份有数据的聊天列表"""
records = self._load_month_records(year, month)
if role_name:
records = [r for r in records if r.roleName == role_name]
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
return sorted(list(chats))
# 全局实例
token_usage_service = TokenUsageService()

View File

@@ -4,9 +4,12 @@ LLM 客户端工具
提供统一的 LLM 接口,支持多种模型提供商。
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
"""
from typing import Optional
from typing import Optional, List, Dict, Any, AsyncGenerator
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
from langchain_core.callbacks import AsyncCallbackHandler
from core.config import settings
import time
def get_llm(
@@ -86,3 +89,249 @@ def get_creative_llm(provider: str = "openai") -> BaseChatModel:
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
"""获取支持流式输出的 LLM"""
return get_llm(provider, streaming=True)
class TokenUsageCallbackHandler(AsyncCallbackHandler):
"""
Token 使用回调处理器
用于捕获 LLM 调用的 token 使用情况
"""
def __init__(self):
self.prompt_tokens = 0
self.completion_tokens = 0
self.total_tokens = 0
self.response_content = ""
async def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str] = None, **kwargs):
"""LLM 开始时的回调"""
pass
async def on_llm_end(self, response, **kwargs):
"""LLM 结束时的回调,获取 token 统计"""
try:
# 从 response 中提取 token 信息
if hasattr(response, 'llm_output') and response.llm_output:
token_usage = response.llm_output.get('token_usage', {})
self.prompt_tokens = token_usage.get('prompt_tokens', 0)
self.completion_tokens = token_usage.get('completion_tokens', 0)
self.total_tokens = token_usage.get('total_tokens', 0)
except Exception as e:
print(f"[TokenUsageCallback] 提取 token 信息失败: {e}")
async def on_llm_new_token(self, token: str, **kwargs):
"""每个新 token 的回调(流式输出)"""
self.response_content += token
class LLMClient:
"""
LLM 客户端封装类
提供统一的异步接口支持自定义API配置、流式输出和 token 统计
"""
async def chat_completion(
self,
messages: List[BaseMessage],
api_url: str,
api_key: str,
model: str = "gpt-3.5-turbo",
temperature: float = 1.0,
max_tokens: int = 500,
request_timeout: int = 60,
stream: bool = False,
**kwargs
) -> Dict[str, Any]:
"""
调用 LLM API 生成回复
Args:
messages: LangChain 消息列表
api_url: API 地址
api_key: API 密钥
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
request_timeout: 请求超时时间(秒)
stream: 是否启用流式输出
**kwargs: 其他参数
Returns:
OpenAI 格式的响应字典,包含 token 使用信息
"""
try:
from langchain_openai import ChatOpenAI
# 创建回调处理器
callback_handler = TokenUsageCallbackHandler()
# 创建自定义的 ChatOpenAI 实例
llm = ChatOpenAI(
model=model,
temperature=temperature,
api_key=api_key,
base_url=api_url if api_url else None,
max_tokens=max_tokens,
streaming=stream,
callbacks=[callback_handler],
request_timeout=request_timeout, # ✅ 设置超时时间
**kwargs
)
start_time = time.time()
if stream:
# 流式模式
full_content = ""
async for chunk in llm.astream(messages):
if hasattr(chunk, 'content'):
full_content += chunk.content
duration = time.time() - start_time
return {
"choices": [
{
"message": {
"role": "assistant",
"content": full_content
}
}
],
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
else:
# 非流式模式
response = await llm.ainvoke(messages)
duration = time.time() - start_time
return {
"choices": [
{
"message": {
"role": "assistant",
"content": response.content
}
}
],
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
except Exception as e:
print(f"[LLMClient] 调用失败: {e}")
raise
async def stream_chat(
self,
messages: List[BaseMessage],
api_url: str,
api_key: str,
model: str = "gpt-3.5-turbo",
temperature: float = 1.0,
max_tokens: int = 500,
request_timeout: int = 60,
**kwargs
) -> AsyncGenerator[Dict[str, Any], None]:
"""
流式调用 LLM API
Args:
messages: LangChain 消息列表
api_url: API 地址
api_key: API 密钥
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
request_timeout: 请求超时时间(秒)
**kwargs: 其他参数
Yields:
包含 token 片段的字典
"""
try:
from langchain_openai import ChatOpenAI
print(f"\n[LLMClient] 🔧 创建 ChatOpenAI 实例")
print(f" - Model: {model}")
print(f" - API URL: {api_url[:50]}..." if len(api_url) > 50 else f" - API URL: {api_url}")
print(f" - Temperature: {temperature}")
print(f" - Max Tokens: {max_tokens}")
print(f" - Request Timeout: {request_timeout}s")
# 创建回调处理器
callback_handler = TokenUsageCallbackHandler()
# 创建自定义的 ChatOpenAI 实例
llm = ChatOpenAI(
model=model,
temperature=temperature,
api_key=api_key,
base_url=api_url if api_url else None,
max_tokens=max_tokens,
streaming=True,
callbacks=[callback_handler],
request_timeout=request_timeout, # ✅ 设置超时时间
**kwargs
)
start_time = time.time()
print(f"[LLMClient] 🚀 开始流式请求...")
print(f" - Messages 数量: {len(messages)}")
if messages:
first_msg_role = getattr(messages[0], 'role', 'unknown')
first_msg_preview = str(getattr(messages[0], 'content', ''))[:50]
print(f" - 第一条消息: [{first_msg_role}] {first_msg_preview}...")
chunk_count = 0
# 流式输出
async for chunk in llm.astream(messages):
if hasattr(chunk, 'content') and chunk.content:
chunk_count += 1
# 第一个 chunk 时记录
if chunk_count == 1:
first_chunk_time = time.time()
print(f"[LLMClient] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
yield {
"type": "chunk",
"content": chunk.content
}
duration = time.time() - start_time
print(f"[LLMClient] ✅ 流式请求完成")
print(f" - 总 Chunks: {chunk_count}")
print(f" - 耗时: {duration:.2f}")
print(f" - Prompt Tokens: {callback_handler.prompt_tokens}")
print(f" - Completion Tokens: {callback_handler.completion_tokens}")
print(f" - Total Tokens: {callback_handler.total_tokens}\n")
# 最后发送 token 使用信息
yield {
"type": "usage",
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
except Exception as e:
print(f"\n[LLMClient] ❌ 流式调用失败: {e}")
import traceback
traceback.print_exc()
raise

View File

@@ -1,201 +0,0 @@
"""
检查前端世界书功能与后端API的匹配情况
"""
print("=" * 80)
print("前端世界书功能与后端API路由匹配检查")
print("=" * 80)
# 前端Store中的API调用
frontend_calls = [
{
"功能": "获取世界书列表",
"方法": "GET",
"路径": "/api/worldbooks/",
"Store函数": "fetchWorldBooks"
},
{
"功能": "获取指定世界书",
"方法": "GET",
"路径": "/api/worldbooks/{name}",
"Store函数": "fetchWorldBook"
},
{
"功能": "创建世界书",
"方法": "POST",
"路径": "/api/worldbooks/",
"Store函数": "createWorldBook",
"备注": "FormData: name, is_global, file"
},
{
"功能": "更新世界书",
"方法": "PUT",
"路径": "/api/worldbooks/{name}",
"Store函数": "updateWorldBook",
"备注": "FormData: is_global, file"
},
{
"功能": "删除世界书",
"方法": "DELETE",
"路径": "/api/worldbooks/{name}",
"Store函数": "deleteWorldBook"
},
{
"功能": "获取世界书条目(分页)",
"方法": "GET",
"路径": "/api/worldbooks/{name}/entries?page={page}&page_size={page_size}",
"Store函数": "fetchWorldBookEntries",
"参数": "page=1, page_size=20 (默认)"
},
{
"功能": "获取指定条目",
"方法": "GET",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "fetchWorldBookEntry"
},
{
"功能": "创建条目",
"方法": "POST",
"路径": "/api/worldbooks/{name}/entries",
"Store函数": "createWorldBookEntry",
"备注": "JSON body: entryData (包含trigger_config)"
},
{
"功能": "更新条目",
"方法": "PUT",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "updateWorldBookEntry",
"备注": "JSON body: entryData (包含trigger_config)"
},
{
"功能": "删除条目",
"方法": "DELETE",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "deleteWorldBookEntry"
},
{
"功能": "导入世界书",
"方法": "POST",
"路径": "/api/worldbooks/{name}/import",
"Store函数": "importWorldBook",
"备注": "FormData: file"
},
{
"功能": "导出世界书",
"方法": "GET",
"路径": "/api/worldbooks/{name}/export?format={format}",
"Store函数": "exportWorldBook",
"参数": "format='internal''sillytavern'"
}
]
# 后端API路由
backend_routes = [
{"方法": "GET", "路径": "/worldbooks/", "函数": "list_worldbooks"},
{"方法": "GET", "路径": "/worldbooks/{name}", "函数": "get_worldbook"},
{"方法": "POST", "路径": "/worldbooks/", "函数": "create_worldbook"},
{"方法": "PUT", "路径": "/worldbooks/{name}", "函数": "update_worldbook"},
{"方法": "DELETE", "路径": "/worldbooks/{name}", "函数": "delete_worldbook"},
{"方法": "GET", "路径": "/worldbooks/{name}/entries", "函数": "list_worldbook_entries", "参数": "page, page_size"},
{"方法": "GET", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "get_worldbook_entry"},
{"方法": "POST", "路径": "/worldbooks/{name}/entries", "函数": "create_worldbook_entry"},
{"方法": "PUT", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "update_worldbook_entry"},
{"方法": "DELETE", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "delete_worldbook_entry"},
{"方法": "POST", "路径": "/worldbooks/{name}/import", "函数": "import_worldbook"},
{"方法": "GET", "路径": "/worldbooks/{name}/export", "函数": "export_worldbook", "参数": "format"}
]
print("\n✅ 前端API调用清单:\n")
for i, call in enumerate(frontend_calls, 1):
print(f"{i}. {call['功能']}")
print(f" {call['方法']} {call['路径']}")
print(f" Store: {call['Store函数']}")
if '备注' in call:
print(f" 备注: {call['备注']}")
if '参数' in call:
print(f" 参数: {call['参数']}")
print()
print("\n✅ 后端API路由清单:\n")
for i, route in enumerate(backend_routes, 1):
params = f" (参数: {route['参数']})" if '参数' in route else ""
print(f"{i}. {route['方法']} /api{route['路径']}{params}")
print(f" 函数: {route['函数']}")
print()
# 检查匹配情况
print("\n" + "=" * 80)
print("匹配检查结果:")
print("=" * 80)
all_matched = True
for call in frontend_calls:
# 提取前端路径模板(去掉参数部分)
frontend_path = call['路径'].split('?')[0].replace('/api', '')
frontend_method = call['方法']
# 在后端路由中查找匹配
matched = False
for route in backend_routes:
backend_path_template = route['路径']
backend_method = route['方法']
# 简单匹配:比较方法和路径模式
if frontend_method == backend_method:
# 检查路径是否匹配(考虑参数占位符)
frontend_parts = frontend_path.strip('/').split('/')
backend_parts = backend_path_template.strip('/').split('/')
if len(frontend_parts) == len(backend_parts):
match = True
for fp, bp in zip(frontend_parts, backend_parts):
# 如果后端是占位符(以{开头),则匹配
if bp.startswith('{') and bp.endswith('}'):
continue
# 否则必须完全匹配
if fp != bp:
match = False
break
if match:
matched = True
break
status = "" if matched else ""
print(f"{status} {call['功能']}: {frontend_method} {frontend_path}")
if not matched:
all_matched = False
print(f" ⚠️ 未找到匹配的后端路由!")
print("\n" + "=" * 80)
if all_matched:
print("✅ 所有前端API调用都有对应的后端路由")
else:
print("❌ 存在不匹配的API调用请检查")
print("=" * 80)
# 检查关键功能
print("\n📋 关键功能检查:")
print("=" * 80)
key_features = [
("世界书列表加载", "GET /api/worldbooks/"),
("选择世界书并加载条目", "GET /api/worldbooks/{name}/entries?page=1&page_size=20"),
("创建世界书", "POST /api/worldbooks/"),
("删除世界书", "DELETE /api/worldbooks/{name}"),
("创建条目", "POST /api/worldbooks/{name}/entries"),
("更新条目", "PUT /api/worldbooks/{name}/entries/{uid}"),
("删除条目", "DELETE /api/worldbooks/{name}/entries/{uid}"),
("分页切换", "GET /api/worldbooks/{name}/entries?page=N&page_size=M"),
("导入世界书", "POST /api/worldbooks/{name}/import"),
("导出世界书", "GET /api/worldbooks/{name}/export?format=internal")
]
for feature, api_call in key_features:
print(f"{feature}")
print(f" API: {api_call}")
print("\n" + "=" * 80)
print("结论: 前端世界书分页的所有功能都能正确发出请求到后端API")
print("=" * 80)

View File

@@ -1,49 +0,0 @@
"""
检查世界书路径和文件
"""
from pathlib import Path
# 项目根目录
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
DATA_PATH = PROJECT_ROOT / 'data'
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
print("=" * 60)
print("世界书路径检查")
print("=" * 60)
print(f"\n1. 项目根目录: {PROJECT_ROOT}")
print(f" 存在: {PROJECT_ROOT.exists()}")
print(f"\n2. 数据目录: {DATA_PATH}")
print(f" 存在: {DATA_PATH.exists()}")
print(f"\n3. 世界书目录: {WORLDBOOKS_PATH}")
print(f" 存在: {WORLDBOOKS_PATH.exists()}")
if WORLDBOOKS_PATH.exists():
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f" JSON文件数量: {len(json_files)}")
if json_files:
print(f" 文件列表:")
for f in json_files:
print(f" - {f.name} ({f.stat().st_size} bytes)")
else:
print(f" ⚠️ 世界书目录为空没有JSON文件")
# 检查目录下是否有子目录
subdirs = list(WORLDBOOKS_PATH.iterdir())
if subdirs:
print(f" 子目录/文件:")
for item in subdirs:
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
else:
print(f" ❌ 世界书目录不存在!")
# 检查data目录下有什么
if DATA_PATH.exists():
print(f"\n4. data目录内容:")
for item in DATA_PATH.iterdir():
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
print("\n" + "=" * 60)

View File

@@ -1,53 +0,0 @@
"""
清空默认头像图片中的嵌入JSON数据
"""
from PIL import Image
import io
def clear_png_text_data(png_path):
"""
清空PNG文件中的tEXt文本数据包括嵌入的JSON
Args:
png_path: PNG文件路径
"""
try:
# 打开图片
img = Image.open(png_path)
print(f"原始PNG文本数据键: {list(img.text.keys()) if img.text else ''}")
# 创建一个新的图片对象,复制像素数据但不复制文本数据
if img.mode == 'RGBA':
new_img = Image.new('RGBA', img.size)
else:
new_img = Image.new('RGB', img.size)
# 复制像素数据
new_img.paste(img)
# 确保新图片没有文本数据
new_img.text = {}
# 保存回原文件
buffer = io.BytesIO()
new_img.save(buffer, format='PNG')
buffer.seek(0)
with open(png_path, 'wb') as f:
f.write(buffer.read())
# 验证是否已清空
verify_img = Image.open(png_path)
print(f"清空后PNG文本数据键: {list(verify_img.text.keys()) if verify_img.text else ''}")
print(f"✓ 成功清空 {png_path} 中的JSON数据")
except Exception as e:
print(f"✗ 处理失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
png_file = 'data/characters/defult.png'
print(f"正在处理: {png_file}")
clear_png_text_data(png_file)

View File

@@ -1,87 +0,0 @@
"""
将现有的 SillyTavern 格式世界书转换为内部格式
"""
import json
from pathlib import Path
import sys
# 添加backend目录到Python路径
backend_dir = Path(__file__).parent / "backend"
sys.path.insert(0, str(backend_dir))
from models.converters import WorldBookConverter
WORLDBOOKS_PATH = Path(r'D:\progarm\python\llm_workflow_engine\data\worldbooks')
print("=" * 60)
print("世界书格式转换工具")
print("=" * 60)
# 查找所有JSON文件
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f"\n找到 {len(json_files)} 个世界书文件\n")
converted_count = 0
skipped_count = 0
error_count = 0
for file_path in json_files:
print(f"处理: {file_path.name}")
try:
# 读取文件
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 检测格式
format_type = WorldBookConverter.detect_format(data)
print(f" 当前格式: {format_type}")
if format_type == "sillytavern":
# 需要转换
name = data.get("name", file_path.stem)
print(f" 世界书名称: {name}")
print(f" 条目数量: {len(data.get('entries', {}))}")
# 转换为内部格式
internal_data = WorldBookConverter.st_to_internal(data, name)
# 备份原文件
backup_path = file_path.with_suffix('.json.bak')
file_path.rename(backup_path)
print(f" ✓ 已备份原文件为: {backup_path.name}")
# 保存转换后的文件
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(internal_data, f, ensure_ascii=False, indent=2)
print(f" ✓ 转换完成并保存")
print(f" - 新格式: internal")
print(f" - 条目数量: {len(internal_data.get('entries', []))}")
converted_count += 1
elif format_type == "internal":
print(f" 已是内部格式,跳过")
skipped_count += 1
else:
print(f" ⚠️ 未知格式,跳过")
skipped_count += 1
except Exception as e:
print(f" ❌ 错误: {e}")
import traceback
traceback.print_exc()
error_count += 1
print()
print("=" * 60)
print("转换完成统计:")
print(f" ✓ 转换成功: {converted_count} 个文件")
print(f" - 跳过(已是内部格式): {skipped_count} 个文件")
print(f" ✗ 转换失败: {error_count} 个文件")
print("=" * 60)
if converted_count > 0:
print("\n提示: 原文件已备份为 .bak 后缀,确认无误后可删除")

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,23 +0,0 @@
{
"id": "test-character-1",
"name": "测试角色1",
"description": "这是一个测试角色,用于验证角色卡功能",
"personality": "友好、乐于助人、幽默",
"scenario": "日常对话场景",
"first_mes": "你好我是测试角色1很高兴见到你",
"mes_example": "",
"categories": ["测试", "示例"],
"tags": ["test", "demo", "friendly"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [
"嗨!有什么我可以帮你的吗?",
"欢迎来到测试世界!"
],
"createdAt": 1700000000,
"updatedAt": 1700000000,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

View File

@@ -1,4 +0,0 @@
{"user_name": "User", "character_name": "测试角色1", "create_date": "2026-04-30T15:00:00Z"}
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:01Z", "mes": "你好我是测试角色1很高兴见到你"}
{"name": "User", "is_user": true, "send_date": "2026-04-30T15:00:10Z", "mes": "你好!今天过得怎么样?"}
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:15Z", "mes": "我很好,谢谢关心!你呢?"}

View File

@@ -1,20 +0,0 @@
{
"id": "test-character-2",
"name": "测试角色2",
"description": "第二个测试角色,有不同的标签",
"personality": "严肃、专业、认真",
"scenario": "工作场景",
"first_mes": "您好我是测试角色2请问有什么工作需要处理",
"mes_example": "",
"categories": ["测试", "工作"],
"tags": ["test", "professional", "work"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [],
"createdAt": 1700000100,
"updatedAt": 1700000100,
"lastChatAt": null,
"isFavorite": true,
"version": 1
}

View File

@@ -1,4 +0,0 @@
{"user_name": "User", "character_name": "测试角色2", "create_date": "2026-04-30T16:00:00Z"}
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:01Z", "mes": "您好我是测试角色2请问有什么工作需要处理"}
{"name": "User", "is_user": true, "send_date": "2026-04-30T16:00:10Z", "mes": "帮我分析一下这个数据"}
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:20Z", "mes": "好的,请提供数据,我会进行专业分析。"}

View File

@@ -1,2 +0,0 @@
{"user_name": "User", "character_name": "测试角色2", "integrity": "644e9983-2102-4608-aeb5-64016c1ba92a", "chat_id_hash": "3f37a950-dda3-4341-97a8-50e58da796ce", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143941", "mes": "您好我是测试角色2请问有什么工作需要处理", "extra": {}, "swipes": [], "swipe_id": 0}

View File

@@ -1,22 +0,0 @@
{
"id": "test-character-3",
"name": "测试角色3",
"description": "第三个测试角色与角色1有相同的tag",
"personality": "活泼、开朗、爱开玩笑",
"scenario": "娱乐场景",
"first_mes": "嘿嘿我是测试角色3让我们来玩吧",
"mes_example": "",
"categories": ["测试", "娱乐"],
"tags": ["test", "demo", "fun"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [
"哟呼!"
],
"createdAt": 1700000200,
"updatedAt": 1700000200,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

View File

@@ -1,2 +0,0 @@
{"user_name": "User", "character_name": "测试角色3", "integrity": "dc677e4e-dd79-43ad-bbf9-ca886d176a0d", "chat_id_hash": "2fa6e72e-dce1-4f70-9e2d-0bc0ebce3bc1", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "测试角色3", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143944", "mes": "嘿嘿我是测试角色3让我们来玩吧", "extra": {}, "swipes": [], "swipe_id": 0}

BIN
data/chat/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
data/encryption_key.txt Normal file
View File

@@ -0,0 +1 @@
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=

View File

@@ -9,6 +9,7 @@
"repetition_penalty": 1,
"openai_max_context": 4095,
"openai_max_tokens": 300,
"request_timeout": 60,
"names_behavior": 0,
"send_if_empty": "",
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
@@ -31,52 +32,12 @@
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
"identifier": "main"
},
{
"name": "Auxiliary Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"name": "Post-History Instructions",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"identifier": "charDescription",
"name": "Char Description",
@@ -95,6 +56,46 @@
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"name": "Auxiliary Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"name": "Post-History Instructions",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "personaDescription",
"name": "Persona Description",
@@ -214,5 +215,8 @@
"continue_prefill": false,
"continue_postfix": " ",
"seed": -1,
"n": 1
"n": 1,
"updatedAt": 1777857993,
"name": "Default",
"createdAt": 1777977985
}

View File

@@ -0,0 +1,28 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [2],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,35 @@
{
"id": "rule-hide-thinking-001",
"name": "隐藏思考标签",
"description": null,
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [
2
],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"createdAt": 1777997833,
"updatedAt": 1777997833,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"createdAt": 1777997834,
"updatedAt": 1777997834,
"version": 1,
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,10 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [],
"createdAt": 1777998120,
"updatedAt": 1777998120,
"version": 1,
"isSillyTavernFormat": false
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
{
"id": "6acc2ea6-0dd2-4542-b9f5-0bcefe2a5947",
"name": "test 规则集",
"description": "从 SillyTavern 导入的规则",
"rules": [
{
"id": "af24f3c2-9ded-4593-a6db-98449c022696",
"scriptName": "test",
"findRegex": "test",
"replaceString": "",
"trimStrings": [],
"placement": [
2
],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "preset",
"characterName": null,
"presetName": "test",
"disabled": false,
"order": 0,
"createdAt": 1777995980,
"updatedAt": 1777995980,
"description": null
}
],
"createdAt": 1777995980,
"updatedAt": 1777995980,
"version": 1,
"isSillyTavernFormat": true
}

View File

@@ -0,0 +1,7 @@
{
"thinkingTagPrefix": "<thinking>",
"thinkingTagSuffix": "</thinking>",
"currentPresetName": null,
"updatedAt": 1777798988,
"version": 1
}

View File

@@ -0,0 +1,14 @@
{"id": "08267c9f-a53c-40cc-b47a-b24c54309d83", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 2, "promptTokens": 73, "completionTokens": 4, "totalTokens": 77, "status": "completed", "errorMessage": null, "timestamp": 1777984056, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "dd88534a-2947-4ad2-be81-ffedfb9dfe02", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 3, "promptTokens": 133, "completionTokens": 213, "totalTokens": 346, "status": "completed", "errorMessage": null, "timestamp": 1777984358, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "5d3980d1-6c9d-4f6c-ae89-ec1b93b4d4c5", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 156, "totalTokens": 604, "status": "completed", "errorMessage": null, "timestamp": 1777984695, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "c939e2d6-9bce-4484-8a07-b49dfc51ca46", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 104, "totalTokens": 807, "status": "completed", "errorMessage": null, "timestamp": 1777986964, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "6cf8bfc1-73aa-4c52-92b6-184d40a57fea", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 42, "totalTokens": 745, "status": "completed", "errorMessage": null, "timestamp": 1777987419, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "e702de1c-3fed-45cc-87cc-fa42745cdeb6", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 8, "promptTokens": 841, "completionTokens": 72, "totalTokens": 913, "status": "completed", "errorMessage": null, "timestamp": 1777989342, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "320c1ed6-bbd7-487c-b61c-148a76605fcc", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 10, "promptTokens": 1009, "completionTokens": 43, "totalTokens": 1052, "status": "completed", "errorMessage": null, "timestamp": 1777989675, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "2bd0cf29-4451-462a-9b4a-c643ceeca3d0", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 12, "promptTokens": 1146, "completionTokens": 19, "totalTokens": 1165, "status": "completed", "errorMessage": null, "timestamp": 1777990077, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "4ff6d3c6-c986-470b-bd63-6d811fd38651", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 14, "promptTokens": 1258, "completionTokens": 26, "totalTokens": 1284, "status": "completed", "errorMessage": null, "timestamp": 1777990303, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "b62eb2a9-e7fe-497f-a9a2-86d523ac1087", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 246, "totalTokens": 694, "status": "completed", "errorMessage": null, "timestamp": 1777990887, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "1458daed-02b6-403f-b153-71a3fcca411f", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 7, "promptTokens": 694, "completionTokens": 266, "totalTokens": 960, "status": "completed", "errorMessage": null, "timestamp": 1777990994, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "e98598a6-18ba-4d9b-98aa-d2b6f2fbf519", "chatId": "写卡机/chat_1777998326762", "roleName": "写卡机", "chatName": "chat_1777998326762", "messageId": null, "floor": 2, "promptTokens": 2019, "completionTokens": 868, "totalTokens": 2887, "status": "completed", "errorMessage": null, "timestamp": 1777998427, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "85e7d5ff-4bf7-409f-8fbf-9687722a5a6d", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 1977, "completionTokens": 1104, "totalTokens": 3081, "status": "completed", "errorMessage": null, "timestamp": 1778068763, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "1bd77edf-1b40-4222-a6b9-2091e27fafae", "chatId": "神国之主/chat_1778166975812", "roleName": "神国之主", "chatName": "chat_1778166975812", "messageId": null, "floor": 3, "promptTokens": 2438, "completionTokens": 1272, "totalTokens": 3710, "status": "completed", "errorMessage": null, "timestamp": 1778167133, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}

View File

@@ -0,0 +1,10 @@
{
"https://api.deepseek.com/v1": {
"totalPromptTokens": 13890,
"totalCompletionTokens": 4435,
"totalTokens": 18325,
"count": 14,
"firstUsed": 1777984056,
"lastUsed": 1778167133
}
}

View File

@@ -0,0 +1,20 @@
{
"2026-05-05": {
"promptTokens": 9475,
"completionTokens": 2059,
"totalTokens": 11534,
"count": 12
},
"2026-05-06": {
"promptTokens": 1977,
"completionTokens": 1104,
"totalTokens": 3081,
"count": 1
},
"2026-05-07": {
"promptTokens": 2438,
"completionTokens": 1272,
"totalTokens": 3710,
"count": 1
}
}

View File

@@ -39,8 +39,6 @@ services:
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://backend:8000
- VITE_WS_URL=ws://backend:8000
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
depends_on:
backend:

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 层级体系 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import {
require_react_dom
} from "./chunk-TYILIMWK.js";
import "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}

View File

@@ -1,6 +0,0 @@
import {
require_react
} from "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react();
//# sourceMappingURL=react.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,44 +1,33 @@
// frontend-react/src/App.jsx
import React, { useState, useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react'; // ✅ 移除 useState
import TopBar from './components/TopBar';
import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
import useWorldBookStore from './Store/SideBarLeft/WorldBookSlice'; // ✅ 引入世界书 Store
import './index.css';
function App() {
// 布局模式:'chat'(聊天模式) | 'edit'(编辑模式)
const [layoutMode, setLayoutMode] = useState('chat');
// 左侧栏模式:'fixed'(固定)| 'smart'(智能)| 'expanded'(扩展)
const [sidebarMode, setSidebarMode] = useState(() => {
return localStorage.getItem('sidebarMode') || 'smart';
});
// 智能模式的悬停状态
const [isSidebarHovered, setIsSidebarHovered] = useState(false);
// ✅ 从 AppLayoutStore 获取状态和方法
const {
layoutMode,
sidebarMode,
isSidebarHovered,
colorTheme,
setLayoutMode,
setSidebarMode,
setSidebarHovered,
setColorTheme
} = useAppLayoutStore();
// 防抖定时器引用
const hoverTimeoutRef = useRef(null);
const leaveTimeoutRef = useRef(null);
// 配色主题
const [colorTheme, setColorTheme] = useState(() => {
return localStorage.getItem('colorTheme') || 'default';
});
// 保存左侧栏模式到 LocalStorage
useEffect(() => {
localStorage.setItem('sidebarMode', sidebarMode);
}, [sidebarMode]);
// 保存配色主题到 LocalStorage
useEffect(() => {
localStorage.setItem('colorTheme', colorTheme);
// 应用主题到 document
document.documentElement.setAttribute('data-color-theme', colorTheme);
}, [colorTheme]);
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
const handleMouseEnter = useCallback(() => {
if (sidebarMode === 'smart') {
@@ -49,11 +38,11 @@ function App() {
// 设置防抖延迟后展开
hoverTimeoutRef.current = setTimeout(() => {
setIsSidebarHovered(true);
setSidebarHovered(true); // ✅ 使用 store 方法
setLayoutMode('edit');
}, 400); // 400ms 防抖
}
}, [sidebarMode]);
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
const handleMouseLeave = useCallback(() => {
@@ -65,11 +54,11 @@ function App() {
// 设置延迟收起,给用户反应时间
leaveTimeoutRef.current = setTimeout(() => {
setIsSidebarHovered(false);
setSidebarHovered(false); // ✅ 使用 store 方法
setLayoutMode('chat');
}, 250); // 250ms 延迟
}
}, [sidebarMode]);
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
// 清理定时器
useEffect(() => {
@@ -79,34 +68,129 @@ function App() {
};
}, []);
// 更新左侧栏模式(从设置面板调用
const updateSidebarMode = useCallback((mode) => {
setSidebarMode(mode);
// 切换模式时重置悬停状态
setIsSidebarHovered(false);
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
// ✅ 初始化时应用主题到 DOM确保页面加载时就显示正确的主题
useEffect(() => {
document.documentElement.setAttribute('data-theme', colorTheme);
document.documentElement.setAttribute('data-color-theme', colorTheme);
}, [colorTheme]);
if (mode === 'expanded') {
setLayoutMode('edit');
// ✅ 应用启动时自动加载必要的配置数据
useEffect(() => {
// console.log('[App] 🚀 应用启动,开始加载默认配置...');
const startTime = Date.now();
// 获取各个 Store 的方法
const apiConfigStore = useApiConfigStore.getState();
const presetStore = usePresetStore.getState();
const characterStore = useCharacterStore.getState();
const worldBookStore = useWorldBookStore.getState();
// 并行加载所有必要的数据
Promise.allSettled([
// 1. 加载 API 配置文件列表
(async () => {
try {
await apiConfigStore.fetchProfiles();
// console.log('[App] ✅ API 配置文件列表加载完成');
// ✅ 如果有持久化的配置 ID优先使用否则加载第一个
const persistedProfileId = apiConfigStore.currentProfileId;
const currentProfile = apiConfigStore.currentProfile;
if (persistedProfileId && !currentProfile) {
// 有持久化 ID 但没有详情,加载详情
// console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`);
await apiConfigStore.fetchProfile(persistedProfileId);
console.log('[App] ✅ API 配置详情加载完成');
} else if (!currentProfile) {
// 没有持久化配置,加载第一个
const profiles = useApiConfigStore.getState().profiles;
if (profiles.length > 0) {
const firstProfile = profiles[0];
// console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`);
await apiConfigStore.fetchProfile(firstProfile.id);
// console.log('[App] ✅ API 配置详情加载完成');
} else {
setLayoutMode('chat');
// console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
}
}, []);
} else {
// 从缓存恢复
}
} catch (err) {
// console.error('[App] ❌ API 配置加载失败:', err);
}
})(),
// 更新配色主题(从设置面板调用)
const updateColorTheme = useCallback((theme) => {
setColorTheme(theme);
}, []);
// 2. 加载预设列表
(async () => {
try {
await presetStore.fetchPresets();
console.log('[App] ✅ 预设列表加载完成');
// ✅ 如果有持久化的预设,优先使用
const persistedPreset = presetStore.selectedPreset;
if (persistedPreset) {
console.log(`[App] 🔄 恢复上次选中的预设: ${persistedPreset}`);
// 重新加载预设详情以获取最新配置
await presetStore.setSelectedPreset(persistedPreset);
console.log('[App] ✅ 预设详情加载完成');
} else {
// 没有持久化预设,选择第一个
const presets = usePresetStore.getState().presets;
if (presets.length > 0) {
const firstPreset = presets[0];
console.log(`[App] 📝 自动选择第一个预设: ${firstPreset.name}`);
presetStore.selectPreset(firstPreset.name);
} else {
// console.warn('[App] ⚠️ 没有可用的预设');
}
}
} catch (err) {
// console.error('[App] ❌ 预设列表加载失败:', err);
}
})(),
// 3. 加载角色卡列表
(async () => {
try {
await characterStore.fetchCharacters();
// console.log('[App] ✅ 角色卡列表加载完成');
} catch (err) {
// console.error('[App] ❌ 角色卡列表加载失败:', err);
}
})(),
// 4. 加载世界书列表
(async () => {
try {
await worldBookStore.fetchWorldBooks();
// console.log('[App] ✅ 世界书列表加载完成');
} catch (err) {
// console.error('[App] ❌ 世界书列表加载失败:', err);
}
})()
]).then((results) => {
const endTime = Date.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
// 统计加载结果
const successCount = results.filter(r => r.status === 'fulfilled').length;
const failCount = results.filter(r => r.status === 'rejected').length;
// console.log(`[App] 🎉 配置加载完成 (${duration}s)`);
// console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`);
// if (failCount > 0) {
// console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用');
// }
});
}, []); // 仅在应用启动时执行一次
return (
<div className={`app ${layoutMode}-mode`}>
<TopBar
sidebarMode={sidebarMode}
colorTheme={colorTheme}
onSidebarModeChange={updateSidebarMode}
onColorThemeChange={updateColorTheme}
/>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}
<TopBar />
{/* 主内容容器 */}
<div className="main-container">

View File

@@ -0,0 +1,96 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* App 布局状态 Store
* 管理应用整体布局和主题(持久化)
*/
const useAppLayoutStore = create(
persist(
(set) => ({
// ==================== 状态 ====================
// 布局模式:'chat' | 'workflow' | 'settings'
layoutMode: 'chat',
// 侧边栏模式:'left' | 'right' | 'both' | 'none'
sidebarMode: 'both',
// 侧边栏是否悬停
isSidebarHovered: false,
// 颜色主题:'light' | 'dark'
colorTheme: 'dark',
// ==================== Actions ====================
/**
* 设置布局模式
* @param {string} mode - 布局模式
*/
setLayoutMode: (mode) => {
set({ layoutMode: mode });
},
/**
* 设置侧边栏模式
* @param {string} mode - 侧边栏模式
*/
setSidebarMode: (mode) => {
set({ sidebarMode: mode });
},
/**
* 切换侧边栏悬停状态
* @param {boolean} hovered - 是否悬停
*/
setSidebarHovered: (hovered) => {
set({ isSidebarHovered: hovered });
},
/**
* 设置颜色主题
* @param {string} theme - 主题名
*/
setColorTheme: (theme) => {
set({ colorTheme: theme });
// 同步到 DOM
document.documentElement.setAttribute('data-color-theme', theme);
},
/**
* 切换颜色主题
*/
toggleColorTheme: () => {
set((state) => {
const newTheme = state.colorTheme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-color-theme', newTheme);
return { colorTheme: newTheme };
});
},
/**
* 重置所有布局状态
*/
reset: () => {
set({
layoutMode: 'chat',
sidebarMode: 'both',
isSidebarHovered: false,
colorTheme: 'dark'
});
}
}),
{
name: 'app-layout-storage', // localStorage key
partialize: (state) => ({
layoutMode: state.layoutMode,
sidebarMode: state.sidebarMode,
colorTheme: state.colorTheme
})
}
)
);
export default useAppLayoutStore;

View File

@@ -3,6 +3,8 @@ import { create } from 'zustand';
import { subscribeWithSelector, persist } from 'zustand/middleware';
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
import usePresetStore from '../SideBarLeft/PresetSlice';
import useCharacterStore from '../SideBarLeft/CharacterSlice'; // 引入角色卡 Store
import useWorldBookStore from '../SideBarLeft/WorldBookSlice'; // 引入世界书 Store
const useChatBoxStore = create(
subscribeWithSelector(
@@ -39,8 +41,8 @@ const useChatBoxStore = create(
dynamicTable: false, // 动态表格
streamOutput: false, // 流式输出
imageWorkflow: false, // 生图工作流
htmlRender: false, // HTML渲染
markdownRender: true, // Markdown渲染(默认开启
renderMode: 'markdown', // 渲染模式: 'none' | 'html' | 'markdown'
autoDiceRoll: false, // 自动掷骰子替换(默认关闭
},
// 设置消息列表
@@ -80,14 +82,35 @@ const useChatBoxStore = create(
dynamicTable: false,
streamOutput: false,
imageWorkflow: false,
htmlRender: false,
markdownRender: true
renderMode: 'markdown',
autoDiceRoll: false
}
}),
// 切换渲染模式 (none -> html -> markdown -> none)
cycleRenderMode: () => set((state) => {
const modes = ['none', 'html', 'markdown'];
const currentIndex = modes.indexOf(state.options.renderMode);
const nextIndex = (currentIndex + 1) % modes.length;
return {
options: {
...state.options,
renderMode: modes[nextIndex]
}
};
}),
// 设置渲染模式
setRenderMode: (mode) => set((state) => ({
options: {
...state.options,
renderMode: mode
}
})),
// 同时设置角色和聊天
setChatBoxRoleAndChat: (role, chat) => {
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
// console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
set({
currentRole: role,
currentChat: typeof chat === 'object' && chat !== null ? chat.chat_name : chat
@@ -105,40 +128,123 @@ const useChatBoxStore = create(
},
// 发送消息
sendMessage: async (content) => {
sendMessage: async (content, targetFloor = null) => {
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
// ✅ 如果启用了自动掷骰子,处理内容
let processedContent = content;
if (options.autoDiceRoll) {
const result = get().processDiceRoll(content);
processedContent = result.content;
if (result.hasDiceCommand) {
console.log('[DiceRoll] 自动替换掷骰指令:', { original: content, processed: processedContent });
}
}
// ✅ 如果没有 currentChat先创建聊天文件
let actualChat = currentChat;
if (!currentChat && currentRole) {
// console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`);
try {
const chatName = '默认聊天';
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_name: chatName,
metadata: {
user_name: userName || 'User',
character_name: characterName || currentRole
}
})
});
if (response.ok || response.status === 400) {
actualChat = chatName;
// 更新 currentChat
set({ currentChat: chatName });
// console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`);
} else {
throw new Error(`创建聊天失败: ${response.status}`);
}
} catch (error) {
// console.error('[ChatBoxStore] ❌ 创建聊天失败:', error);
set({ error: '创建聊天失败: ' + error.message });
return;
}
}
// 获取 API 配置
const apiConfigStore = useApiConfigStore.getState();
// 获取预设配置
const presetStore = usePresetStore.getState();
// ✅ 获取角色卡数据
const characterStore = useCharacterStore.getState();
const selectedCharacter = characterStore.selectedCharacter;
// ✅ 获取世界书数据
const worldBookStore = useWorldBookStore.getState();
const globalWorldBooks = worldBookStore.globalWorldBooks;
// 关闭之前的WebSocket连接
if (wsConnection) {
wsConnection.close();
}
// 计算下一个楼层号
const nextFloor = get().getNextFloor(messages);
// ✅ 判断是重roll还是新消息
const isReroll = targetFloor !== null;
let userFloor, assistantFloor, nextFloor;
if (isReroll) {
// 重roll模式不创建新用户消息楼层直接使用目标楼层的上一条用户消息
// console.log('[ChatBoxStore] 🔄 重roll模式目标楼层:', targetFloor);
// 找到目标 AI 消息
const targetMessage = messages.find(m => m.floor === targetFloor);
if (!targetMessage || targetMessage.is_user) {
// console.error('[ChatBoxStore] ❌ 无效的目标楼层');
return;
}
// 助手楼层就是目标楼层
assistantFloor = targetFloor;
nextFloor = targetFloor; // ✅ 设置 nextFloor 用于后续发送
// ✅ 不添加新的用户消息,只准备更新 AI 消息
set({
isGenerating: true,
wsConnection: null, // 重置连接
});
} else {
// 正常模式:创建新的用户消息和 AI 消息
nextFloor = get().getNextFloor(messages);
userFloor = nextFloor;
assistantFloor = nextFloor + 1;
set({
isGenerating: true,
wsConnection: null, // 重置连接
messages: [...messages, {
id: Date.now(),
floor: nextFloor,
mes: content,
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
floor: userFloor,
mes: processedContent, // 使用处理后的内容
is_user: true,
name: userName || 'User',
sendDate: new Date().toISOString()
}]
});
}
try {
// 统一使用WebSocket处理流式和非流式输出
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/ws`;
// WebSocket 直接连接到后端,使用浏览器的 host
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host.replace('23338', '23337'); // 前端 23338 -> 后端 23337
const wsUrl = `${wsProtocol}//${wsHost}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
const ws = new WebSocket(wsUrl);
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
@@ -158,9 +264,25 @@ const useChatBoxStore = create(
// 保存WebSocket连接到store
set({ wsConnection: ws });
// 添加一个空的助手消息,稍后会更新
const newMessageId = Date.now();
const assistantFloor = nextFloor + 1; // 助手消息的楼层是用户消息楼层+1
// ✅ 根据模式处理 AI 消息
let newMessageId;
if (isReroll) {
// 重roll模式找到目标消息并准备添加新的 swipe
const targetMessage = messages.find(m => m.floor === assistantFloor);
if (!targetMessage) {
// console.error('[ChatBoxStore] ❌ 找不到目标消息');
return;
}
newMessageId = targetMessage.id; // 使用现有的 ID
// console.log('[ChatBoxStore] 🔄 重roll模式更新消息:', newMessageId);
// ✅ 不添加新消息,只是标记为正在生成
set({ isGenerating: true });
} else {
// 正常模式:添加一个空的助手消息,稍后会更新
newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID
set((state) => ({
messages: [...state.messages, {
id: newMessageId,
@@ -169,44 +291,163 @@ const useChatBoxStore = create(
is_user: false,
name: characterName || 'Assistant',
sendDate: new Date().toISOString()
}]
}],
isGenerating: true
}));
}
let assistantMessage = '';
let isStreamComplete = false;
ws.onopen = () => {
clearTimeout(connectionTimeout); // 清除超时定时器
console.log('[WebSocket] 连接已建立', { readyState: ws.readyState });
console.log('\n' + '='.repeat(80));
console.log('[WebSocket] 📡 连接已建立');
console.log(' - URL:', wsUrl);
console.log(' - Ready State:', ws.readyState);
console.log('='.repeat(80) + '\n');
};
ws.onclose = (event) => {
console.log('[WebSocket] 连接已关闭', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
console.log('\n' + '='.repeat(80));
console.log('[WebSocket] 🔌 连接已关闭');
console.log(' - Code:', event.code);
console.log(' - Reason:', event.reason);
console.log(' - Was Clean:', event.wasClean);
console.log('='.repeat(80) + '\n');
};
// 处理WebSocket消息
let chunkCount = 0;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('[WebSocket] 收到消息', { type: data.type, content: data.content });
if (data.type === 'chunk') {
chunkCount++;
// 每10个chunk记录一次
if (chunkCount % 10 === 0) {
console.log(`[WebSocket] 📊 已接收 ${chunkCount} 个 chunks`);
}
// 处理流式数据块
assistantMessage += data.content;
if (isReroll) {
// ✅ 重roll模式不更新 mes只在内部累积 assistantMessage
// mes 保持不变,直到 complete 事件才更新
} else {
// 正常模式:更新新创建的消息
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
)
}));
}
} else if (data.type === 'worldbook_active') {
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
// ✅ 更新世界书激活显示
import('../../Store/SideBarRight/WorldBookActiveSlice').then(module => {
module.default.getState().setActiveEntries(data.entries);
});
} else if (data.type === 'tasks_created') {
console.log('[WebSocket] 📋 收到任务ID信息:', data.tasks);
// ✅ 创建了新任务
import('../../Store/SideBarRight/TasksSlice').then(module => {
const tasksStore = module.default;
const newTasks = [];
if (data.tasks.imageWorkflow) {
newTasks.push({
taskId: data.tasks.imageWorkflow,
taskType: 'image_workflow',
chatId: `${currentRole}/${actualChat}`
});
}
if (data.tasks.dynamicTable) {
newTasks.push({
taskId: data.tasks.dynamicTable,
taskType: 'dynamic_table',
chatId: `${currentRole}/${actualChat}`
});
}
if (newTasks.length > 0) {
tasksStore.getState().addTasks(newTasks);
}
});
} else if (data.type === 'task_status_update') {
console.log('[WebSocket] 🔄 收到任务状态更新:', data.tasks);
// ✅ 任务状态更新
import('../../Store/SideBarRight/TasksSlice').then(module => {
module.default.getState().setTasks(data.tasks);
});
} else if (data.type === 'task_cancelled') {
console.log('[WebSocket] ❌ 任务取消确认:', data.taskId);
// ✅ 任务取消确认
import('../../Store/SideBarRight/TasksSlice').then(module => {
module.default.getState().updateTaskStatus(data.taskId, 'cancelled');
});
} else if (data.type === 'interrupted') {
console.log('\n[WebSocket] 🛑 收到中断信号');
console.log(' - 已生成内容长度:', data.content?.length || 0);
// ✅ 处理中断:保存已生成的部分内容
isStreamComplete = true;
ws.close();
set({ wsConnection: null, isGenerating: false });
} else if (data.type === 'complete') {
console.log('\n[WebSocket] ✅ 收到完成信号');
console.log(' - 总 Chunks:', chunkCount);
console.log(' - 消息长度:', assistantMessage.length);
// ✅ 处理重roll模式将新生成的内容添加到 swipes 数组
if (isReroll) {
// console.log('[ChatBoxStore] 🔄 重roll完成添加新的 swipe 版本');
set((state) => ({
messages: state.messages.map((msg) => {
if (msg.id === newMessageId) {
// 获取现有的 swipes 数组
const existingSwipes = msg.swipes || [];
const currentMes = msg.mes; // 当前显示的内容(旧版本)
// 构建新的 swipes 数组:包含所有旧版本 + 新版本
let updatedSwipes = [...existingSwipes];
// 如果当前 mes 不在 swipes 中,先添加它
if (!updatedSwipes.includes(currentMes)) {
updatedSwipes.push(currentMes);
}
// 添加新生成的内容
updatedSwipes.push(assistantMessage);
// console.log('[ChatBoxStore] 📊 Swipes 更新:', {
// oldCount: existingSwipes.length,
// newCount: updatedSwipes.length,
// newSwipeIndex: updatedSwipes.length - 1,
// currentMesLength: currentMes.length,
// assistantMessageLength: assistantMessage.length
// });
return {
...msg,
mes: assistantMessage, // 显示新生成的内容
swipes: updatedSwipes, // 更新 swipes 数组
swipe_id: updatedSwipes.length - 1 // 自动切换到新版本
};
}
return msg;
})
}));
}
// 完成响应
isStreamComplete = true;
ws.close();
set({ wsConnection: null, isGenerating: false });
} else if (data.type === 'error') {
console.error('[WebSocket] ❌ 收到错误:', data.message);
// 错误处理
set({
error: data.message,
@@ -236,36 +477,97 @@ const useChatBoxStore = create(
}
};
// 发送请求到WebSocket确保连接已建立
// 发送请求到WebSocket确保连接已建立
const sendAfterConnect = () => {
console.log('[WebSocket] 📤 sendAfterConnect 被调用, readyState:', ws.readyState);
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] 发送消息', { readyState: ws.readyState });
ws.send(JSON.stringify({
floor: nextFloor,
mes: content,
console.log('[WebSocket] ✅ 连接已打开,准备发送消息');
// ✅ 获取 API 配置
const apiConfigData = {
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
api_key: apiConfigStore.currentProfile?.apis?.mainLLM?.apiKey ? '***' : '',
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
};
// console.log('\n' + '-'.repeat(80));
// console.log('[WebSocket] 📤 发送消息:');
// console.log(' - Floor:', isReroll ? assistantFloor : nextFloor);
// console.log(' - Mode:', isReroll ? '🔄 Reroll (添加swipe)' : ' New Message');
// console.log(' - Role:', currentRole);
// console.log(' - Chat:', actualChat);
// console.log(' - Stream:', options.streamOutput);
// console.log(' - Message Length:', processedContent.length);
// console.log(' - API Config:', apiConfigData);
// console.log(' - Current Profile:', apiConfigStore.currentProfile);
// console.log('-'.repeat(80) + '\n');
// ✅ 实际发送消息
const messageData = JSON.stringify({
floor: isReroll ? assistantFloor : nextFloor, // ✅ 重roll模式使用目标楼层
mes: processedContent, // 使用处理后的内容
is_user: true,
currentRole: currentRole,
currentChat: currentChat,
currentChat: actualChat, // 使用 actualChat
options: options,
apiConfig: {
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
api_key: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_key || ''
// 从 currentProfile 中获取 mainLLM 配置(不包含 apiKey
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
},
// ✅ 传递 profileId让后端从配置文件读取 API Key
currentProfile: {
id: apiConfigStore.currentProfile?.id || null
},
presetConfig: {
selectedPreset: presetStore.selectedPreset,
parameters: presetStore.parameters,
promptComponents: presetStore.promptComponents
},
stream: options.streamOutput
}));
// ✅ 角色卡数据
characterData: selectedCharacter ? {
id: selectedCharacter.id,
name: selectedCharacter.name,
description: selectedCharacter.description,
personality: selectedCharacter.personality,
scenario: selectedCharacter.scenario,
first_mes: selectedCharacter.first_mes,
mes_example: selectedCharacter.mes_example,
worldInfoId: selectedCharacter.worldInfoId || null, // 绑定的世界书ID
tags: selectedCharacter.tags || [],
categories: selectedCharacter.categories || []
} : null,
// ✅ 世界书数据
worldBookData: {
globalBooks: globalWorldBooks.map(wb => ({
id: wb.id,
name: wb.name,
description: wb.description
})),
characterBookId: selectedCharacter?.worldInfoId || null // 角色绑定的世界书ID
},
// ✅ 动态表格数据(如果启用)
dynamicTableData: options.dynamicTable ? {
headers: selectedCharacter?.tableHeaders || [],
currentValues: selectedCharacter?.tableDefaults || {}
} : null,
// ✅ 时间戳(用于冲突解决)
timestamp: Date.now(),
stream: options.streamOutput,
// ✅ 调试标志请求后端返回完整的prompt拼接内容
debugPrompt: true
});
// console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length);
ws.send(messageData);
// console.log('[WebSocket] ✅ 消息已发送');
} else if (ws.readyState === WebSocket.CONNECTING) {
// 如果正在连接,继续等待
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
// console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
setTimeout(sendAfterConnect, 100);
} else {
// 连接失败或已关闭
console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
// console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
set({
error: 'WebSocket connection failed',
isGenerating: false,
@@ -285,9 +587,24 @@ const useChatBoxStore = create(
// 终止生成
stopGeneration: () => set((state) => {
if (state.wsConnection && state.wsConnection.readyState === WebSocket.OPEN) {
// console.log('[ChatBoxStore] 🛑 发送终止信号...');
// ✅ 先发送取消任务信号给后端
state.wsConnection.send(JSON.stringify({
type: 'cancel_task',
taskId: 'current_llm_generation' // 标记为当前LLM生成任务
}));
// 等待一小段时间让后端处理
setTimeout(() => {
if (state.wsConnection) {
state.wsConnection.close();
// console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
}
}, 100);
}
return {
isGenerating: false,
wsConnection: null
@@ -296,6 +613,14 @@ const useChatBoxStore = create(
// 加载聊天历史
fetchChatHistory: async (roleName, chatName) => {
const currentState = get();
// 如果已经在加载中,跳过
if (currentState.isLoading) {
// console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
return;
}
set({ isLoading: true, error: null });
try {
// 确俚chatName是字符串
@@ -317,31 +642,36 @@ 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,
name: characterData.name || roleName,
sendDate: new Date().toISOString()
}];
console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
// console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
}
}
} catch (error) {
console.warn('[ChatBoxStore] 获取角色信息失败:', error);
// console.warn('[ChatBoxStore] 获取角色信息失败:', error);
}
}
// 只更新消息相关状态,不更新 currentRole/currentChat避免触发监听器
set({
messages: messages,
userName: data.metadata?.user_name || 'User',
characterName: data.metadata?.character_name || roleName || 'Assistant',
isLoading: false
});
// console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
} catch (error) {
set({
error: error.message,
isLoading: false
});
// console.error('[ChatBoxStore] 加载聊天失败:', error);
}
},
@@ -473,7 +803,37 @@ const useChatBoxStore = create(
partialize: (state) => ({
// 只持久化选项状态,不持久化聊天历史等
options: state.options
})
}),
merge: (persistedState, currentState) => {
// 合并持久化状态和当前状态
const merged = {
...currentState,
...persistedState,
};
// 确保 options 中的所有字段都存在,使用默认值填充缺失的字段
if (persistedState?.options) {
merged.options = {
dynamicTable: persistedState.options.dynamicTable ?? false,
streamOutput: persistedState.options.streamOutput ?? false,
imageWorkflow: persistedState.options.imageWorkflow ?? false,
// 兼容旧版本:如果存在 htmlRender/markdownRender转换为 renderMode
renderMode: (() => {
// 优先使用新的 renderMode
if (persistedState.options.renderMode) {
return persistedState.options.renderMode;
}
// 兼容旧版本的 htmlRender/markdownRender
if (persistedState.options.markdownRender) return 'markdown';
if (persistedState.options.htmlRender) return 'html';
return 'none';
})(),
autoDiceRoll: persistedState.options.autoDiceRoll ?? false,
};
}
return merged;
}
}
)
)
@@ -483,16 +843,49 @@ const useChatBoxStore = create(
useChatBoxStore.subscribe(
(state) => ({ role: state.currentRole, chat: state.currentChat }),
({ role, chat }, prev) => {
console.log(`[ChatBoxStore 监听器] 状态变化检测:`, {
当前: { role, chat },
之前: prev,
角色变化: role !== prev.role,
聊天变化: chat !== prev.chat
});
// 只有当角色或聊天发生变化时才处理
if (role !== prev.role || chat !== prev.chat) {
// 确保角色和聊天都存在且不为null
if (role && chat) {
// 确保chat是字符串如果是对象则提取chat_name
// 确保角色存在
if (role) {
// 如果聊天也存在,加载聊天历史
if (chat) {
// 确俚chat是字符串如果是对象则提取chat_name
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
// 检查是否已经在加载中,避免重复加载
const currentState = useChatBoxStore.getState();
console.log(`[ChatBoxStore 监听器] isLoading 状态:`, currentState.isLoading);
if (!currentState.isLoading) {
console.log(`[ChatBoxStore 监听器] ✅ 开始加载聊天: ${role}/${actualChat}`);
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
} else {
console.log(`[ChatBoxStore 监听器] ⏭️ 跳过加载,正在加载中: ${role}/${actualChat}`);
}
} else {
// 聊天为 null只设置角色不加载聊天等待用户发送第一条消息
console.log(`[ChatBoxStore 监听器] 只设置角色,未选择聊天: ${role}`);
useChatBoxStore.setState({
currentRole: role,
currentChat: null,
messages: [], // 清空消息
characterName: role // 使用角色名作为显示名称
});
}
} else {
// 角色也为 null完全清空
console.log(`[ChatBoxStore 监听器] 🗑️ 清空所有状态`);
useChatBoxStore.getState().clearChatHistory();
}
} else {
console.log(`[ChatBoxStore 监听器] ⏸️ 无变化,不触发加载`);
}
},
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }

View File

@@ -0,0 +1,154 @@
import { create } from 'zustand';
/**
* ChatBox UI 状态 Store
* 管理聊天框的 UI 交互状态(非持久化)
*/
const useChatBoxUIStore = create((set) => ({
// ==================== 状态 ====================
// 当前编辑的消息 ID
editingId: null,
// 编辑中的内容
editContent: '',
// 输入框内容
inputValue: '',
// 是否显示选项面板
showOptions: false,
// 是否显示聊天选择器
showChatSelector: false,
// 角色的聊天列表
characterChats: [],
// 当前 swipe ID用于多版本切换
currentSwipeId: {},
// 输入框高度
inputHeight: 42,
// ==================== Actions ====================
/**
* 开始编辑消息
* @param {string|number} messageId - 消息 ID
* @param {string} content - 消息内容
*/
startEditing: (messageId, content) => {
set({
editingId: messageId,
editContent: content
});
},
/**
* 取消编辑
*/
cancelEditing: () => {
set({
editingId: null,
editContent: ''
});
},
/**
* 更新编辑内容
* @param {string} content - 新内容
*/
updateEditContent: (content) => {
set({ editContent: content });
},
/**
* 设置输入框内容
* @param {string} value - 输入值
*/
setInputValue: (value) => {
set({ inputValue: value });
},
/**
* 清空输入框
*/
clearInput: () => {
set({ inputValue: '' });
},
/**
* 切换选项面板
*/
toggleOptions: () => {
set((state) => ({ showOptions: !state.showOptions }));
},
/**
* 设置选项面板显示状态
* @param {boolean} show - 是否显示
*/
setShowOptions: (show) => {
set({ showOptions: show });
},
/**
* 切换聊天选择器
*/
toggleChatSelector: () => {
set((state) => ({ showChatSelector: !state.showChatSelector }));
},
/**
* 设置聊天选择器显示状态
* @param {boolean} show - 是否显示
*/
setShowChatSelector: (show) => {
set({ showChatSelector: show });
},
/**
* 设置角色聊天列表
* @param {Array} chats - 聊天列表
*/
setCharacterChats: (chats) => {
set({ characterChats: chats });
},
/**
* 设置当前 swipe ID
* @param {Object} swipeId - swipe ID 对象 { [messageId]: swipeIndex }
*/
setCurrentSwipeId: (swipeId) => {
set((state) => ({
currentSwipeId: { ...state.currentSwipeId, ...swipeId }
}));
},
/**
* 设置输入框高度
* @param {number} height - 高度px
*/
setInputHeight: (height) => {
set({ inputHeight: height });
},
/**
* 重置所有 UI 状态
*/
reset: () => {
set({
editingId: null,
editContent: '',
inputValue: '',
showOptions: false,
showChatSelector: false,
characterChats: [],
currentSwipeId: {},
inputHeight: 42
});
}
}));
export default useChatBoxUIStore;

View File

@@ -1,2 +1,3 @@
// Mid 区域相关的 Store
export { default as useChatBoxStore } from './ChatBoxSlice';
export { default as useChatBoxUIStore } from './ChatBoxUISlice';

View File

@@ -27,6 +27,7 @@ const useApiConfigStore = create(
set({ loading: true, error: null });
try {
const response = await fetch('/api/api-config/profiles');
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
@@ -47,7 +48,11 @@ const useApiConfigStore = create(
throw new Error('Failed to fetch profile');
}
const data = await response.json();
set({ currentProfile: data, loading: false });
set({
currentProfile: data,
currentProfileId: profileId, // ✅ 保存当前配置 ID
loading: false
});
return data;
} catch (err) {
set({ error: err.message, loading: false });
@@ -193,8 +198,27 @@ const useApiConfigStore = create(
{
name: 'ApiConfigStore',
partialize: (state) => ({
activeMap: state.activeMap
})
activeMap: state.activeMap,
// ✅ 持久化当前选中的配置文件 ID
currentProfileId: state.currentProfile?.id || null
}),
// ✅ 恢复时自动加载对应的配置详情
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('[ApiConfigStore] 恢复状态失败:', error);
return;
}
if (state?.currentProfileId) {
console.log(`[ApiConfigStore] 🔄 恢复上次选中的配置: ${state.currentProfileId}`);
// 异步加载配置详情
setTimeout(() => {
useApiConfigStore.getState().fetchProfile(state.currentProfileId).catch(err => {
console.error('[ApiConfigStore] 加载配置详情失败:', err);
});
}, 0);
}
}
}
)
);

View File

@@ -0,0 +1,151 @@
import { create } from 'zustand';
/**
* CharacterCard UI 状态 Store
* 管理角色卡列表的 UI 交互状态(非持久化)
*/
const useCharacterCardUIStore = create((set) => ({
// ==================== 状态 ====================
// 筛选标签数组 - 支持多标签交集筛选
// 格式: ['include:tag1', 'exclude:tag2', 'include:tag3']
filterTags: [],
// 是否处于编辑模式
isEditing: false,
// 编辑表单数据
editForm: null,
// 当前页码
currentPage: 1,
// 每页显示数量
pageSize: 12,
// ==================== Actions ====================
/**
* 设置筛选标签(三次切换:无筛选 -> 包含 -> 排除 -> 无筛选)
* @param {string} tag - 标签名
*/
setFilterTag: (tag) => {
set((state) => {
const currentFilter = state.filterTag;
// 如果点击的是同一个标签,循环切换状态
if (currentFilter && (currentFilter === tag || currentFilter === `include:${tag}` || currentFilter === `exclude:${tag}`)) {
if (currentFilter === tag || currentFilter === `include:${tag}`) {
// 从包含切换到排除
return { filterTag: `exclude:${tag}`, currentPage: 1 };
} else {
// 从排除切换到无筛选
return { filterTag: '', currentPage: 1 };
}
} else {
// 点击新标签,设置为包含模式
return { filterTag: `include:${tag}`, currentPage: 1 };
}
});
},
/**
* 清空筛选
*/
clearFilter: () => {
set({ filterTag: '', currentPage: 1 });
},
/**
* 进入编辑模式
* @param {Object} character - 角色数据
*/
startEditing: (character) => {
set({
isEditing: true,
editForm: {
name: character.name,
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 || {}
}
});
},
/**
* 退出编辑模式
*/
cancelEditing: () => {
set({
isEditing: false,
editForm: null
});
},
/**
* 更新编辑表单字段
* @param {string} field - 字段名
* @param {*} value - 字段值
*/
updateEditForm: (field, value) => {
set((state) => ({
editForm: {
...state.editForm,
[field]: value
}
}));
},
/**
* 设置页码
* @param {number} page - 页码
*/
setCurrentPage: (page) => {
set({ currentPage: page });
},
/**
* 设置每页显示数量
* @param {number} size - 每页数量
*/
setPageSize: (size) => {
set({ pageSize: size, currentPage: 1 }); // 重置页码
},
/**
* 下一页
*/
nextPage: () => {
set((state) => ({ currentPage: state.currentPage + 1 }));
},
/**
* 上一页
*/
prevPage: () => {
set((state) => ({ currentPage: Math.max(1, state.currentPage - 1) }));
},
/**
* 重置所有 UI 状态
*/
reset: () => {
set({
filterTag: '',
isEditing: false,
editForm: null,
currentPage: 1,
pageSize: 12
});
}
}));
export default useCharacterCardUIStore;

View File

@@ -1,6 +1,113 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // ✅ 添加持久化支持
const usePresetStore = create((set, get) => ({
// ✅ 默认固有组件列表marker=true
const DEFAULT_PROMPT_COMPONENTS = [
{
identifier: "worldInfoBefore",
name: "World Info (before)",
description: "世界书条目,插入在角色设定之前",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "激活的世界书条目(前置)"
},
{
identifier: "worldInfoAfter",
name: "World Info (after)",
description: "世界书条目,插入在角色设定之后",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "激活的世界书条目(后置)"
},
{
identifier: "charDescription",
name: "Char Description",
description: "角色描述,从角色卡提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前角色卡的 description 字段"
},
{
identifier: "charPersonality",
name: "Char Personality",
description: "角色性格,从角色卡提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前角色卡的 personality 字段"
},
{
identifier: "scenario",
name: "Scenario",
description: "场景设定,从角色卡提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前角色卡的 scenario 字段"
},
{
identifier: "personaDescription",
name: "Persona Description",
description: "用户角色描述,从用户设定提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前用户设定的 persona 字段"
},
{
identifier: "dialogueExamples",
name: "Dialogue Examples",
description: "对话示例,从角色卡提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前角色卡的 mesExample 字段"
},
{
identifier: "chatHistory",
name: "Chat History",
description: "聊天历史,从当前聊天记录提取",
system_prompt: true,
marker: true,
enabled: true,
role: 0,
dataSource: "当前聊天的消息历史"
},
{
identifier: "authorNotes",
name: "Author's Notes",
description: "作者注释,从角色卡或聊天元数据提取",
system_prompt: true,
marker: true,
enabled: false,
role: 0,
dataSource: "角色卡的 authorNote 或聊天的 authorNotes 字段"
},
{
identifier: "postHistoryInstructions",
name: "Post-History Instructions",
description: "历史记录后指令,从聊天元数据提取",
system_prompt: true,
marker: true,
enabled: false,
role: 0,
dataSource: "当前聊天的 postHistoryInstructions 字段"
}
];
const usePresetStore = create(
persist(
(set, get) => ({
// 预设选择
selectedPreset: '',
@@ -28,6 +135,10 @@ const usePresetStore = create((set, get) => ({
// 参数设置折叠状态
isParametersExpanded: true,
// 分页状态
currentPage: 1,
pageSize: 8,
// 预设组件列表
promptComponents: [
{
@@ -114,7 +225,7 @@ const usePresetStore = create((set, get) => ({
set({ presets: presetList, isLoadingPresets: false });
} catch (error) {
console.error('Failed to fetch presets:', error);
// console.error('Failed to fetch presets:', error);
set({ isLoadingPresets: false });
}
},
@@ -126,32 +237,52 @@ const usePresetStore = create((set, get) => ({
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,
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty : 0.0,
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty : 0.0,
top_p: presetData.top_p !== undefined ? presetData.top_p : 1.0,
top_k: presetData.top_k !== undefined ? presetData.top_k : 0,
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty :
(presetData.frequencyPenalty !== undefined ? presetData.frequencyPenalty : 0.0),
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty :
(presetData.presencePenalty !== undefined ? presetData.presencePenalty : 0.0),
top_p: presetData.top_p !== undefined ? presetData.top_p :
(presetData.topP !== undefined ? presetData.topP : 1.0),
top_k: presetData.top_k !== undefined ? presetData.top_k :
(presetData.topK !== undefined ? presetData.topK : 0),
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
(presetData.max_tokens !== undefined ? presetData.max_tokens : 30000),
(presetData.max_tokens !== undefined ? presetData.max_tokens :
(presetData.maxLength !== undefined ? presetData.maxLength : 30000)),
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
seed: presetData.seed !== undefined ? presetData.seed : -1,
n: presetData.n !== undefined ? presetData.n : 1
};
// 记录映射后的参数用于调试
console.log('映射后的参数:', parameters);
// 处理预设组件
// 处理预设组件 - 支持内部结构和SillyTavern结构
let components = [];
if (presetData.prompts && Array.isArray(presetData.prompts)) {
// 优先使用内部结构的 entries
if (presetData.entries && Array.isArray(presetData.entries)) {
components = presetData.entries.map(entry => ({
identifier: entry.identifier,
name: entry.name,
content: entry.content || '',
enabled: entry.enabled !== false,
role: entry.role === 'system' ? 0 : entry.role === 'user' ? 1 : 2,
system_prompt: entry.role === 'system',
marker: entry.isSystemNode || false
}));
// 按 order 排序
components.sort((a, b) => {
const orderA = presetData.entries.find(e => e.identifier === a.identifier)?.order || 0;
const orderB = presetData.entries.find(e => e.identifier === b.identifier)?.order || 0;
return orderA - orderB;
});
}
// 兼容SillyTavern结构的 prompts
else if (presetData.prompts && Array.isArray(presetData.prompts)) {
// 获取当前角色的prompt_order添加更严格的检查
const currentOrder = (presetData.prompt_order &&
Array.isArray(presetData.prompt_order) &&
@@ -182,6 +313,24 @@ const usePresetStore = create((set, get) => ({
}
}
// ✅ 检查并补全缺失的固有组件marker=true
const BUILTIN_MARKERS = [
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
'authorNotes', 'postHistoryInstructions'
];
const existingIdentifiers = new Set(components.map(c => c.identifier));
for (const markerId of BUILTIN_MARKERS) {
if (!existingIdentifiers.has(markerId)) {
// 查找默认定义
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
if (defaultComponent) {
components.push({ ...defaultComponent });
}
}
}
// 更新状态,确保参数容器展开
set({
@@ -191,7 +340,7 @@ const usePresetStore = create((set, get) => ({
isParametersExpanded: true // 确保参数容器展开
});
} catch (error) {
console.error('Failed to load preset:', error);
// console.error('Failed to load preset:', error);
}
},
@@ -205,23 +354,57 @@ const usePresetStore = create((set, get) => ({
presets: [...state.presets, preset]
})),
// 保存当前设置为预设
// 保存当前设置为预设 - 使用 SillyTavern 标准格式
saveCurrentAsPreset: async ({ name }) => {
const state = get();
try {
// 构建预设数据
// ✅ 检查并补全缺失的固有组件marker=true
const BUILTIN_MARKERS = [
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
'authorNotes', 'postHistoryInstructions'
];
let componentsToSave = [...state.promptComponents];
const existingIdentifiers = new Set(componentsToSave.map(c => c.identifier));
for (const markerId of BUILTIN_MARKERS) {
if (!existingIdentifiers.has(markerId)) {
// 查找默认定义
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
if (defaultComponent) {
componentsToSave.push({ ...defaultComponent });
console.log('[保存预设] ✅ 补全缺失的固有组件:', markerId);
}
}
}
// 构建 SillyTavern 标准格式的预设数据
const presetData = {
...state.parameters,
prompts: state.promptComponents.map(component => ({
// 基本参数 - 使用 SillyTavern 标准字段名
name: name,
temperature: state.parameters.temperature,
frequency_penalty: state.parameters.frequency_penalty,
presence_penalty: state.parameters.presence_penalty,
top_p: state.parameters.top_p,
top_k: state.parameters.top_k,
max_tokens: state.parameters.max_tokens,
request_timeout: state.parameters.request_timeout || 60,
// SillyTavern 标准的 prompts 数组
prompts: componentsToSave.map((component) => ({
identifier: component.identifier,
name: component.name,
content: component.content || '',
role: component.role,
system_prompt: component.system_prompt,
marker: component.marker
system_prompt: component.role === 0,
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'assistant',
enabled: component.enabled !== false
})),
// prompt_order - SillyTavern 用于管理顺序和启用状态
prompt_order: [{
order: state.promptComponents.map(component => ({
character_id: 'global',
order: componentsToSave.map(component => ({
identifier: component.identifier,
enabled: component.enabled !== false
}))
@@ -234,10 +417,7 @@ const usePresetStore = create((set, get) => ({
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
preset_name: name,
...presetData
})
body: JSON.stringify(presetData)
});
if (!response.ok) {
@@ -246,9 +426,9 @@ const usePresetStore = create((set, get) => ({
const result = await response.json();
// 添加到本地预设列表
// 添加到本地预设列表(新预设排在最前面)
const newPreset = {
id: name,
id: name, // 使用名称作为 ID
name,
description: '',
component_count: state.promptComponents.length,
@@ -256,13 +436,13 @@ const usePresetStore = create((set, get) => ({
};
set((state) => ({
presets: [...state.presets, newPreset],
presets: [newPreset, ...state.presets], // ✅ 新预设插入到最前面
selectedPreset: name
}));
return result;
} catch (error) {
console.error('Failed to save preset:', error);
// console.error('Failed to save preset:', error);
throw error;
}
},
@@ -293,7 +473,7 @@ const usePresetStore = create((set, get) => ({
return await response.json();
} catch (error) {
console.error('Failed to update preset name:', error);
// console.error('Failed to update preset name:', error);
throw error;
}
},
@@ -303,37 +483,97 @@ const usePresetStore = create((set, get) => ({
isParametersExpanded: !state.isParametersExpanded
})),
// 设置预设组件列表
setPromptComponents: (components) => set({ promptComponents: components }),
// 设置预设组件列表 - ✅ 自动保存
setPromptComponents: async (components) => {
set({ promptComponents: components });
// 更新组件
updateComponent: (index, updatedComponent) => set((state) => {
// ✅ 自动保存到后端
const state = get();
if (state.selectedPreset) {
try {
await state._autoSavePreset();
} catch (error) {
console.error('[PresetStore] 自动保存失败:', error);
}
}
},
// 更新组件 - ✅ 自动保存
updateComponent: async (index, updatedComponent) => {
set((state) => {
const newComponents = [...state.promptComponents];
newComponents[index] = { ...newComponents[index], ...updatedComponent };
return { promptComponents: newComponents };
}),
});
// 切换组件启用状态
toggleComponentEnabled: (index) => set((state) => {
// ✅ 自动保存到后端
const state = get();
if (state.selectedPreset) {
try {
await state._autoSavePreset();
} catch (error) {
console.error('[PresetStore] 自动保存失败:', error);
}
}
},
// 切换组件启用状态 - ✅ 自动保存
toggleComponentEnabled: async (index) => {
set((state) => {
const newComponents = [...state.promptComponents];
newComponents[index] = {
...newComponents[index],
enabled: !newComponents[index].enabled
};
return { promptComponents: newComponents };
}),
});
// 添加新组件
addComponent: (component) => set((state) => ({
// ✅ 自动保存到后端
const state = get();
if (state.selectedPreset) {
try {
await state._autoSavePreset();
} catch (error) {
console.error('[PresetStore] 自动保存失败:', error);
}
}
},
// 添加新组件 - ✅ 自动保存
addComponent: async (component) => {
set((state) => ({
promptComponents: [...state.promptComponents, component]
})),
}));
// 删除组件
removeComponent: (index) => set((state) => {
// ✅ 自动保存到后端
const state = get();
if (state.selectedPreset) {
try {
await state._autoSavePreset();
} catch (error) {
console.error('[PresetStore] 自动保存失败:', error);
}
}
},
// 删除组件 - ✅ 自动保存
removeComponent: async (index) => {
set((state) => {
const newComponents = [...state.promptComponents];
newComponents.splice(index, 1);
return { promptComponents: newComponents };
}),
});
// ✅ 自动保存到后端
const state = get();
if (state.selectedPreset) {
try {
await state._autoSavePreset();
} catch (error) {
console.error('[PresetStore] 自动保存失败:', error);
}
}
},
// 移动组件位置
moveComponent: (fromIndex, toIndex) => set((state) => {
@@ -343,6 +583,41 @@ const usePresetStore = create((set, get) => ({
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: () => {
const { promptComponents } = get();
@@ -350,7 +625,63 @@ const usePresetStore = create((set, get) => ({
identifier: component.identifier,
enabled: component.enabled !== false
}));
},
// 设置当前页
setCurrentPage: (page) => set({ currentPage: page }),
// 设置每页数量
setPageSize: (size) => set({ pageSize: size, currentPage: 1 }),
// 获取当前页的预设列表
getCurrentPagePresets: () => {
const { presets, currentPage, pageSize } = get();
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return presets.slice(startIndex, endIndex);
},
// 获取总页数
getTotalPages: () => {
const { presets, pageSize } = get();
return Math.ceil(presets.length / pageSize);
}
}));
}), // ✅ 闭合 (set, get) => ({...})
{
name: 'PresetStore', // localStorage 中的键名
partialize: (state) => ({
// ✅ 持久化选中的预设名称
selectedPreset: state.selectedPreset,
// ✅ 持久化参数设置
parameters: state.parameters,
// ✅ 持久化提示词组件配置
promptComponents: state.promptComponents,
// ✅ 持久化折叠状态
isParametersExpanded: state.isParametersExpanded
}),
// ✅ 恢复时的回调
onRehydrateStorage: () => {
return (state, error) => {
if (error) {
console.error('[PresetStore] 恢复状态失败:', error);
return;
}
// 异步加载预设详情
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);
};
}
}
));
export default usePresetStore;

View File

@@ -11,7 +11,8 @@ const useSideBarLeftStore = create(
{ id: 'character', label: '角色', title: '管理AI角色卡' },
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' }
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' },
{ id: 'tokenUsage', label: 'Token', title: '查看 Token 使用统计' }
],
setActiveTab: (tab) => set({ activeTab: tab })

View File

@@ -0,0 +1,122 @@
/**
* Token 使用统计 Store
*/
import { create } from 'zustand';
const useTokenUsageStore = create((set, get) => ({
// 状态
months: [],
currentMonth: null,
stats: null,
roles: [],
chats: [],
selectedRole: null,
selectedChat: null,
loading: false,
error: null,
// Actions
fetchMonths: async () => {
try {
set({ loading: true, error: null });
const response = await fetch('/api/token-usage/months');
if (!response.ok) throw new Error('Failed to fetch months');
const months = await response.json();
set({ months, loading: false });
// 默认选择最新的月份
if (months.length > 0) {
const latest = months[months.length - 1];
await get().fetchStats(latest.year, latest.month);
}
} catch (error) {
set({ error: error.message, loading: false });
}
},
fetchStats: async (year, month, role = null, chat = null) => {
try {
set({ loading: true, error: null, currentMonth: { year, month } });
let url = `/api/token-usage/stats/${year}/${month}`;
const params = new URLSearchParams();
if (role) params.append('role_name', role);
if (chat) params.append('chat_name', chat);
if (params.toString()) {
url += `?${params.toString()}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch stats');
const stats = await response.json();
set({ stats, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
fetchRoles: async (year, month) => {
try {
const response = await fetch(`/api/token-usage/roles/${year}/${month}`);
if (!response.ok) throw new Error('Failed to fetch roles');
const data = await response.json();
set({ roles: data.roles });
} catch (error) {
console.error('Failed to fetch roles:', error);
}
},
fetchChats: async (year, month, role = null) => {
try {
let url = `/api/token-usage/chats/${year}/${month}`;
if (role) {
url += `?role_name=${encodeURIComponent(role)}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch chats');
const data = await response.json();
set({ chats: data.chats });
} catch (error) {
console.error('Failed to fetch chats:', error);
}
},
setSelectedRole: (role) => {
set({ selectedRole: role });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month, role, get().selectedChat);
}
},
setSelectedChat: (chat) => {
set({ selectedChat: chat });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month, get().selectedRole, chat);
}
},
setCurrentMonth: (year, month) => {
set({ currentMonth: { year, month } });
get().fetchStats(year, month, get().selectedRole, get().selectedChat);
get().fetchRoles(year, month);
get().fetchChats(year, month, get().selectedRole);
},
resetFilters: () => {
set({ selectedRole: null, selectedChat: null });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month);
}
},
}));
export default useTokenUsageStore;

View File

@@ -4,3 +4,4 @@ export { default as useApiConfigStore } from './ApiConfigSlice';
export { default as usePresetStore } from './PresetSlice';
export { default as useWorldBookStore } from './WorldBookSlice';
export { default as useCharacterStore } from './CharacterSlice';
export { default as useCharacterCardUIStore } from './CharacterCardUISlice';

View File

@@ -3,15 +3,16 @@ import { persist } from 'zustand/middleware';
const useSideBarRightStore = create(
persist(
(set) => ({
(set, get) => ({
selectedTabs: ['dice', 'rag'],
allTabs: [
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
{ id: 'debug', label: '调试', title: '查看具体发送了哪些上下文', component: null },
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
{ id: 'table', label: '表格', title: '动态数据表格展示与编辑,可直接影响后端', component: null },
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null }
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null },
{ id: 'worldbook_active', label: '世界书', title: '查看当前对话中激活的世界书条目', component: null }, // ✅ 添加世界书激活标签
{ id: 'tasks', label: '任务', title: '查看和管理并行任务(生图、表格维护等)', component: null } // ✅ 添加任务队列标签
],
handleTabClick: (tabId) => set((state) => {
@@ -32,11 +33,46 @@ const useSideBarRightStore = create(
allTabs: state.allTabs.map(tab =>
tab.id === tabId ? { ...tab, component } : tab
)
}))
})),
// ✅ 验证并清理无效的选中标签
validateSelectedTabs: () => {
const state = get();
const validIds = state.allTabs.map(tab => tab.id);
const validSelectedTabs = state.selectedTabs.filter(id => validIds.includes(id));
if (validSelectedTabs.length !== state.selectedTabs.length) {
set({ selectedTabs: validSelectedTabs });
}
}
}),
{
name: 'sidebar-right-storage', // localStorage key
partialize: (state) => ({ selectedTabs: state.selectedTabs }) // 只保存 selectedTabs
partialize: (state) => ({ selectedTabs: state.selectedTabs }), // 只保存 selectedTabs
// ✅ 迁移逻辑:清除已删除的标签
migrate: (persistedState, version) => {
if (persistedState.selectedTabs) {
// 移除已删除的 'debug' 标签
const cleanedTabs = persistedState.selectedTabs.filter(id => id !== 'debug');
// 如果清理后为空或只有一个,恢复到默认值
if (cleanedTabs.length === 0) {
persistedState.selectedTabs = ['dice', 'rag'];
} else if (cleanedTabs.length === 1 && cleanedTabs[0] === 'dice') {
// 如果只剩dice添加rag作为第二个
persistedState.selectedTabs = ['dice', 'rag'];
} else {
persistedState.selectedTabs = cleanedTabs;
}
}
return persistedState;
},
// ✅ 加载后验证
onRehydrateStorage: () => (state) => {
if (state) {
state.validateSelectedTabs();
}
}
}
)
);

View File

@@ -0,0 +1,215 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import useChatBoxStore from '../Mid/ChatBoxSlice'; // ✅ 导入 ChatBoxStore (default export)
/**
* 动态表格 Store
* 管理角色卡的动态表格数据(键值对结构)
*/
const useTableStore = create(
persist(
(set, get) => ({
// ==================== 状态 ====================
// ✅ 动态表格数据(键值对结构)
tableHeaders: [], // 表头数组
tableDefaults: {}, // 默认值对象
// 当前角色和聊天
currentRole: null,
currentChat: null,
// 加载状态
isLoading: false,
// 最后更新时间
lastUpdated: null,
// ==================== Actions ====================
/**
* 加载动态表格数据
* @param {string} role - 角色名
* @param {string} chat - 聊天名
*/
loadTags: async (role, chat) => {
if (!role) {
set({
tableHeaders: [],
tableDefaults: {},
currentRole: null,
currentChat: null
});
return;
}
set({ isLoading: true, currentRole: role, currentChat: chat });
try {
let tableHeaders = [];
let tableDefaults = {};
// ✅ 优先级1从聊天文件读取如果聊天中有动态表格数据
if (chat) {
console.log('[TableStore] 尝试从聊天文件读取动态表格数据');
const response = await fetch(`/api/chat/${encodeURIComponent(role)}/${encodeURIComponent(chat)}`);
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] 聊天中无动态表格,从角色卡读取');
const charResponse = await fetch(`/api/characters/${encodeURIComponent(role)}`);
if (!charResponse.ok) throw new Error('获取角色卡失败');
const charData = await charResponse.json();
tableHeaders = charData.tableHeaders || [];
tableDefaults = charData.tableDefaults || {};
set({
tableHeaders,
tableDefaults,
lastUpdated: Date.now(),
isLoading: false
});
console.log(`[TableStore] ✅ 已从角色卡加载动态表格: ${tableHeaders.length} 个字段`);
} catch (error) {
console.error('[TableStore] 获取动态表格数据失败:', error);
set({
tableHeaders: [],
tableDefaults: {},
isLoading: false
});
}
},
/**
* 清空状态
*/
clear: () => {
set({
tableHeaders: [],
tableDefaults: {},
currentRole: null,
currentChat: null,
isLoading: false,
lastUpdated: null
});
},
/**
* 强制刷新(立即从后端重新加载)
*/
refresh: async () => {
const { currentRole, currentChat } = get();
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) => ({
tableHeaders: state.tableHeaders,
tableDefaults: state.tableDefaults,
currentRole: state.currentRole,
currentChat: state.currentChat,
lastUpdated: state.lastUpdated
})
}
)
);
// ✅ 监听 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

@@ -0,0 +1,88 @@
import { create } from 'zustand';
import useChatBoxStore from '../Mid/ChatBoxSlice';
/**
* 任务队列 Store
* 管理并行任务(生图、动态表格维护等)的状态
*/
const useTasksStore = create((set, get) => ({
// ==================== 状态 ====================
// 任务列表
tasks: [],
// ==================== Actions ====================
/**
* 添加任务
* @param {Object} task - 任务对象 {taskId, taskType, chatId}
*/
addTask: (task) => set((state) => ({
tasks: [...state.tasks, { ...task, status: 'pending' }]
})),
/**
* 批量添加任务
* @param {Array} newTasks - 新任务数组
*/
addTasks: (newTasks) => set((state) => ({
tasks: [...state.tasks, ...newTasks.map(t => ({ ...t, status: 'pending' }))]
})),
/**
* 更新任务状态
* @param {string} taskId - 任务ID
* @param {string} status - 新状态
* @param {Object} metadata - 额外元数据
*/
updateTaskStatus: (taskId, status, metadata = null) => set((state) => ({
tasks: state.tasks.map(task =>
task.taskId === taskId
? { ...task, status, ...(metadata && { metadata }) }
: task
)
})),
/**
* 取消任务
* @param {string} taskId - 任务ID
*/
cancelTask: async (taskId) => {
const { wsConnection } = useChatBoxStore.getState();
if (wsConnection) {
wsConnection.send(JSON.stringify({
type: 'cancel_task',
taskId: taskId
}));
// 乐观更新UI
set((state) => ({
tasks: state.tasks.map(task =>
task.taskId === taskId ? { ...task, status: 'cancelled' } : task
)
}));
}
},
/**
* 清理已完成的任务
*/
clearCompleted: () => set((state) => ({
tasks: state.tasks.filter(task =>
!['completed', 'failed', 'cancelled'].includes(task.status)
)
})),
/**
* 设置任务列表(用于全量更新)
* @param {Array} tasks - 任务列表
*/
setTasks: (tasks) => set({ tasks }),
/**
* 清空所有任务
*/
clearAll: () => set({ tasks: [] })
}));
export default useTasksStore;

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand';
/**
* 世界书激活显示 Store
* 管理当前对话中激活的世界书条目
*/
const useWorldBookActiveStore = create((set, get) => ({
// ==================== 状态 ====================
// 激活的条目列表
activeEntries: [],
// ==================== Actions ====================
/**
* 设置激活的条目(覆盖)
* @param {Array} entries - 激活的条目列表
*/
setActiveEntries: (entries) => set({
activeEntries: entries || []
}),
/**
* 添加单个激活条目
* @param {Object} entry - 条目对象
*/
addActiveEntry: (entry) => set((state) => ({
activeEntries: [...state.activeEntries, entry]
})),
/**
* 清空所有激活条目
*/
clearEntries: () => set({ activeEntries: [] })
}));
export default useWorldBookActiveStore;

View File

@@ -1,2 +1,3 @@
// SideBarRight 相关的 Store
export { default as useSideBarRightStore } from './SideBarRightSlice';
export { default as useTableStore } from './TableSlice';

View File

@@ -0,0 +1,87 @@
// frontend-react/src/Store/UserSlice.jsx
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* 用户角色 Store
* 管理当前玩家角色信息
*/
const useUserStore = create(
persist(
(set, get) => ({
// 当前用户角色
currentUserRole: { name: '', description: '' },
// ✅ 用户角色列表(支持多个角色)
userRoles: [
{ id: 'default', name: '默认用户', description: '普通用户角色' }
],
// 设置用户角色
setCurrentUserRole: (role) => {
set({ currentUserRole: role });
},
// 清除用户角色
clearCurrentUserRole: () => {
set({ currentUserRole: { name: '', description: '' } });
},
// 更新用户角色名称
updateRoleName: (name) => {
set((state) => ({
currentUserRole: { ...state.currentUserRole, name }
}));
},
// 更新用户角色描述
updateRoleDescription: (description) => {
set((state) => ({
currentUserRole: { ...state.currentUserRole, description }
}));
},
// ✅ 添加新用户角色
addUserRole: (role) => {
set((state) => ({
userRoles: [...state.userRoles, { ...role, id: role.id || `role_${Date.now()}` }]
}));
},
// ✅ 删除用户角色
removeUserRole: (roleId) => {
set((state) => ({
userRoles: state.userRoles.filter(r => r.id !== roleId)
}));
},
// ✅ 更新用户角色
updateUserRole: (roleId, updates) => {
set((state) => ({
userRoles: state.userRoles.map(r =>
r.id === roleId ? { ...r, ...updates } : r
)
}));
},
// ✅ 选择用户角色(从列表中选择一个作为当前角色)
selectUserRole: (roleId) => {
set((state) => {
const selectedRole = state.userRoles.find(r => r.id === roleId);
if (selectedRole) {
return { currentUserRole: { ...selectedRole } };
}
return state;
});
}
}),
{
name: 'user-role-storage', // localStorage key
partialize: (state) => ({
currentUserRole: state.currentUserRole
})
}
)
);
export default useUserStore;

View File

@@ -1,6 +1,8 @@
// frontend-react/src/store/index.js
// 统一导出所有 Store方便外部使用
export { useRoleSelectorStore } from './TopBar';
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore } from './SideBarLeft';
export { useSideBarRightStore } from './SideBarRight';
export { useChatBoxStore } from './Mid';
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore, useCharacterCardUIStore } from './SideBarLeft';
export { useSideBarRightStore, useTableStore } from './SideBarRight';
export { useChatBoxStore, useChatBoxUIStore } from './Mid';
export { default as useAppLayoutStore } from './AppLayoutSlice';
export { default as useUserStore } from './UserSlice'; //

View File

@@ -12,7 +12,6 @@
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
padding-bottom: 180px; /* 为固定的输入框留出空间 */
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
@@ -43,7 +42,7 @@
padding: var(--spacing-md) var(--spacing-lg);
border-radius: 0; /* 去掉圆角 */
line-height: 1.8;
position: relative;
position: relative; /* ✅ 为绝对定位的 swipe 按钮提供定位上下文 */
transition: background-color 0.2s ease;
font-size: 1rem;
margin: 0;
@@ -53,19 +52,29 @@
/* 用户消息 - 右侧对齐,用色彩区分 */
.message.user {
align-self: stretch;
background: linear-gradient(to right, var(--color-accent-ultra-light), var(--color-accent-light));
background: linear-gradient(to right, rgba(102, 126, 234, 0.08), rgba(102, 126, 234, 0.15));
color: var(--color-text-primary);
border-left: 3px solid var(--color-accent);
}
/* AI 消息 - 左侧对齐,用色彩区分 */
/* 参考 Discord/Slack/Notion 的夜间模式配色 */
.message.ai {
align-self: stretch;
background-color: var(--color-bg-elevated); /* 使用主题的浅色背景形成对比 */
/* 日间模式:半透明白色背景 */
background-color: rgba(255, 255, 255, 0.6);
color: var(--color-text-primary);
border-left: 3px solid transparent;
}
/* 深色主题下的 AI 消息配色优化 */
[data-color-theme='dark'] .message.ai {
/* 使用深灰色背景,避免纯白色刺眼 */
/* 参考 VS Code / Discord 的深色模式 */
background-color: rgba(45, 45, 48, 0.5);
color: var(--color-text-primary);
}
.message-container {
display: flex;
flex-direction: column;
@@ -129,6 +138,7 @@
.message-content {
position: relative;
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 确保编辑时保持高度 */
}
.bubble {
@@ -162,22 +172,24 @@
flex-direction: column;
gap: var(--spacing-md);
width: 100%;
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 至少保持一行文本的高度 */
}
.edit-textarea {
width: 100%;
min-height: auto; /* 自适应内容高度 */
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 与 bubble 的 padding 保持一致 */
max-height: none; /* 不限制最大高度 */
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
border: none; /* 去掉边框 */
border-radius: 0;
border: 2px solid var(--color-accent); /* ✅ 添加边框提示编辑状态 */
border-radius: var(--radius-sm);
resize: vertical;
font-family: inherit;
font-size: 1rem;
line-height: 1.7;
background-color: transparent; /* 透明背景 */
background-color: rgba(102, 126, 234, 0.05); /* ✅ 轻微背景色区分编辑状态 */
color: var(--color-text-primary);
outline: none;
box-sizing: border-box; /* ✅ 确保宽度计算正确 */
}
.edit-textarea:focus {
@@ -222,36 +234,87 @@
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
}
.swipe-controls {
/* ==================== Swipe Controls - 绝对定位到两侧 ==================== */
.swipe-controls-absolute {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 var(--spacing-sm); /* ✅ 在 padding 区域内 */
pointer-events: none; /* ✅ 让点击事件穿透到消息内容 */
z-index: 10;
}
.swipe-button-side {
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
cursor: pointer;
padding: 6px 12px;
font-size: 0.9rem;
color: var(--color-text-secondary);
transition: all 0.2s ease;
min-width: 36px;
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
justify-content: center;
opacity: 0; /* ✅ 默认隐藏 */
pointer-events: auto; /* ✅ 按钮本身可以接收点击 */
}
.swipe-button {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
padding: 2px 8px;
font-size: 0.8rem;
transition: background-color 0.2s;
/* 深色主题下的 swipe 按钮 */
[data-color-theme='dark'] .swipe-button-side {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.1);
}
.swipe-button:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1);
/* ✅ hover 消息时显示按钮 */
.message:hover .swipe-button-side {
opacity: 0.7;
}
.swipe-button:disabled {
opacity: 0.5;
.swipe-button-side:hover:not(:disabled) {
background-color: rgba(102, 126, 234, 0.2); /* ✅ hover 时更明显 */
border-color: var(--color-accent);
color: var(--color-accent);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
opacity: 1 !important; /* ✅ hover 时完全不透明 */
}
.swipe-button-side:active:not(:disabled) {
transform: translateY(0);
}
.swipe-button-side:disabled {
opacity: 0.2 !important;
cursor: not-allowed;
background-color: rgba(0, 0, 0, 0.05);
}
.swipe-counter {
font-size: 0.8rem;
color: #666;
.swipe-counter-side {
font-size: 0.75rem;
color: var(--color-text-secondary);
font-weight: 600;
min-width: 40px;
text-align: center;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: var(--radius-sm);
opacity: 0; /* ✅ 默认隐藏 */
pointer-events: none;
transition: opacity 0.2s ease;
}
/* ✅ hover 消息时显示页码 */
.message:hover .swipe-counter-side {
opacity: 0.6;
}
/* ==================== Input Area - Fixed at Bottom ==================== */
@@ -263,7 +326,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);
}
@@ -320,27 +383,70 @@
position: absolute;
bottom: calc(100% + var(--spacing-sm));
left: 0;
background: #ffffff;
border: 1px solid rgba(102, 126, 234, 0.2);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: var(--spacing-md);
min-width: 200px;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: var(--spacing-sm);
min-width: 220px;
z-index: var(--z-dropdown-menu); /* ✅ 组件层 - 下拉菜单 */
backdrop-filter: blur(10px);
}
/* 选项组标题 */
.option-group-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
/* 渲染模式按钮 */
.render-mode-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s ease;
text-align: left;
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.render-mode-btn:hover {
background-color: rgba(102, 126, 234, 0.08);
border-color: var(--color-accent);
color: var(--color-accent);
}
.render-mode-btn:active {
transform: scale(0.98);
}
/* 复选框选项 - 简化样式 */
.option-checkbox {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
transition: all 0.15s ease;
margin-bottom: var(--spacing-xs);
}
.option-checkbox:hover {
background-color: rgba(102, 126, 234, 0.05);
background-color: rgba(102, 126, 234, 0.06);
}
.option-checkbox input[type="checkbox"] {
@@ -348,15 +454,19 @@
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid rgba(102, 126, 234, 0.3);
border-radius: 4px;
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-radius: 3px;
position: relative;
transition: all 0.2s ease;
transition: all 0.15s ease;
flex-shrink: 0;
}
.option-checkbox:hover .checkmark {
border-color: var(--color-accent);
}
.option-checkbox input:checked + .checkmark {
background-color: var(--color-accent);
border-color: var(--color-accent);
@@ -365,8 +475,8 @@
.option-checkbox input:checked + .checkmark::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
@@ -378,17 +488,18 @@
font-size: 0.85rem;
color: var(--color-text-primary);
user-select: none;
flex: 1;
}
/* 分隔线 */
.option-divider {
height: 1px;
background: linear-gradient(to right,
transparent,
rgba(102, 126, 234, 0.15),
transparent);
background-color: var(--color-border);
margin: var(--spacing-sm) 0;
opacity: 0.5;
}
/* 切换聊天按钮 */
.switch-chat-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
@@ -399,7 +510,7 @@
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
transition: all 0.15s ease;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
@@ -409,6 +520,10 @@
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.switch-chat-btn:active {
transform: translateY(0);
}
.chat-input-area {
flex: 1;
min-width: 0;
@@ -426,13 +541,20 @@
color: var(--color-text-primary);
font-size: 0.95rem;
resize: none;
overflow-y: hidden;
overflow-y: auto;
min-height: 36px;
max-height: 300px;
transition: all 0.15s ease;
font-family: inherit;
line-height: 1.6;
display: block;
/* ✅ 业界标准:使用 field-sizing 实现自适应高度 */
/* 支持此属性的浏览器会自动根据内容调整高度 */
field-sizing: content;
/* ✅ 兼容性方案:为不支持 field-sizing 的浏览器提供回退 */
height: auto;
}
.message-input:focus {
@@ -518,7 +640,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
}
.chat-selector-modal {
@@ -574,6 +696,40 @@
padding: var(--spacing-lg);
}
/* 新建聊天按钮区域 */
.chat-selector-actions {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.new-chat-btn {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--gradient-primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
}
.new-chat-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
filter: brightness(1.1);
}
.new-chat-btn:active {
transform: translateY(0);
}
.chat-list {
display: flex;
flex-direction: column;
@@ -587,6 +743,10 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
position: relative; /* ✅ 为删除按钮定位 */
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.chat-option:hover {
@@ -596,6 +756,31 @@
.chat-option-content {
cursor: pointer;
flex: 1; /* ✅ 占据剩余空间 */
}
/* ✅ 删除按钮样式 */
.chat-delete-btn {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
opacity: 0; /* 默认隐藏 */
transition: all var(--transition-fast);
color: var(--color-text-muted);
}
.chat-option:hover .chat-delete-btn {
opacity: 0.6; /* hover 时显示 */
}
.chat-delete-btn:hover {
opacity: 1 !important;
background: rgba(255, 77, 77, 0.15);
color: #ff4d4d;
transform: scale(1.1);
}
.chat-option-name {
@@ -626,3 +811,5 @@
margin: 0;
font-size: 0.9rem;
}
/* ✅ 右键菜单样式已移至全局 context-menu.css */

View File

@@ -1,24 +1,50 @@
// frontend-react/src/components/ChatBox/ChatBox.jsx
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
import { processTextForDisplay } from '../../../hooks/useRegexProcessor'; // ✅ 修改:导入普通函数
import MarkdownRenderer from '../../shared/MarkdownRenderer';
import './ChatBox.css';
const ChatBox = () => {
const [editingId, setEditingId] = useState(null);
const [editContent, setEditContent] = useState('');
const messagesEndRef = useRef(null);
const [inputValue, setInputValue] = useState('');
const [showOptions, setShowOptions] = useState(false);
const optionsRef = useRef(null);
// 聊天选择器相关状态
const [showChatSelector, setShowChatSelector] = useState(false);
const [characterChats, setCharacterChats] = useState([]);
const chatSelectorRef = useRef(null);
const messagesContainerRef = useRef(null); // ✅ 新增:消息容器引用
const isUserAtBottomRef = useRef(true); // ✅ 新增:跟踪用户是否在底部
const contextMenuRef = useRef(null); // ✅ 新增:右键菜单引用
// 新增:管理每条消息的当前显示的swipe版本
const [currentSwipeId, setCurrentSwipeId] = useState({});
// 新增:右键菜单状态
const [contextMenu, setContextMenu] = React.useState({
visible: false,
x: 0,
y: 0,
message: null
});
// ✅ 从 ChatBoxUIStore 获取 UI 状态
const {
editingId,
editContent,
inputValue,
showOptions,
showChatSelector,
characterChats,
currentSwipeId,
inputHeight,
startEditing,
cancelEditing,
updateEditContent,
setInputValue,
clearInput,
toggleOptions,
setShowOptions,
toggleChatSelector,
setShowChatSelector,
setCharacterChats,
setCurrentSwipeId,
setInputHeight
} = useChatBoxUIStore();
// 从 ChatBoxStore 获取状态和方法
const {
@@ -32,10 +58,50 @@ const ChatBox = () => {
sendMessage,
stopGeneration,
options,
toggleOption
toggleOption,
cycleRenderMode // ✅ 新增:切换渲染模式
} = useChatBoxStore();
const [inputHeight, setInputHeight] = useState(42);
// ✅ 新增:存储处理后的消息内容
const [processedMessages, setProcessedMessages] = React.useState({});
// ✅ 当消息变化时,预处理正则规则
React.useEffect(() => {
const processMessages = async () => {
const processed = {};
for (const message of messages) {
if (!message.is_user && message.mes) {
// 计算消息深度
const messageDepth = messages.length > 0
? messages[messages.length - 1].floor - message.floor
: 0;
try {
// 异步处理正则规则
const displayText = await processTextForDisplay(
message.mes,
messageDepth,
message.is_user
);
processed[message.floor] = displayText;
} catch (error) {
console.error('[ChatBox] 正则处理失败:', error);
processed[message.floor] = message.mes;
}
} else {
// 用户消息或空消息直接使用原文
processed[message.floor] = message.mes;
}
}
setProcessedMessages(processed);
};
if (messages.length > 0) {
processMessages();
}
}, [messages]);
// 点击外部关闭选项面板
useEffect(() => {
@@ -73,13 +139,34 @@ const ChatBox = () => {
};
}, [showChatSelector]);
// ✅ 点击外部关闭右键菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(event.target)) {
closeContextMenu();
}
};
if (contextMenu.visible) {
document.addEventListener('mousedown', handleClickOutside);
// ✅ 添加滚动监听,防止滚动时菜单不关闭
document.addEventListener('scroll', closeContextMenu, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', closeContextMenu, true);
};
}, [contextMenu.visible]);
const handleInputHeight = (e) => {
const textarea = e.target;
// 标准方案:先重置为 auto再设置为 scrollHeight
// 这是业界公认的最佳实践,确保高度计算准确
// ✅ 业界标准方案:先重置为 auto再设置为 scrollHeight
// 这是 SillyTavern、Discord、Slack 等成熟产品使用的方案
// 确保删除内容时高度也能正确收缩
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
// 处理发送或终止
@@ -88,8 +175,12 @@ const ChatBox = () => {
stopGeneration();
} else {
sendMessage(inputValue);
setInputValue('');
setInputHeight(24); // 重置为一行高度
clearInput(); // ✅ 使用 store 方法
// 重置输入框高度:直接操作 DOM 元素重置为 auto
const textarea = document.querySelector('.message-input');
if (textarea) {
textarea.style.height = 'auto';
}
}
};
@@ -101,33 +192,120 @@ const ChatBox = () => {
}
};
// 自动滚动到底部
const scrollToBottom = () => {
// 自动滚动到底部(智能滚动)
const scrollToBottom = (force = false) => {
// 只有在用户在底部或强制滚动时才执行
if (isUserAtBottomRef.current || force) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
};
// 检测用户是否在底部
const checkIfUserAtBottom = () => {
if (!messagesContainerRef.current) return true;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
// 允许 20px 的误差范围
const isAtBottom = scrollHeight - scrollTop - clientHeight < 20;
isUserAtBottomRef.current = isAtBottom;
return isAtBottom;
};
// 监听消息容器的滚动事件
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
checkIfUserAtBottom();
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, []);
// 当消息变化时,智能滚动
useEffect(() => {
scrollToBottom();
}, [messages]);
// ✅ 监听全局键盘事件 - 左右键切换 swipe 或触发重roll
useEffect(() => {
const handleGlobalKeyDown = (e) => {
// 如果正在输入框中输入,不处理
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') {
return;
}
// 只处理左右方向键
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') {
return;
}
e.preventDefault();
// 找到最后一条 AI 消息
const lastAiMessage = [...messages].reverse().find(m => !m.is_user);
if (!lastAiMessage) {
return;
}
const hasSwipes = lastAiMessage.swipes && lastAiMessage.swipes.length > 0;
if (e.key === 'ArrowLeft') {
// 左键:如果有 swipe切换到上一个版本否则不做任何操作
if (hasSwipes) {
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
? currentSwipeId[lastAiMessage.floor]
: lastAiMessage.swipe_id;
if (currentIndex > 0) {
handleSwipeChange(lastAiMessage.floor, -1);
}
}
// 如果没有 swipe左键不做任何操作
} else if (e.key === 'ArrowRight') {
// 右键:如果有 swipe 且不是最后一个版本切换到下一个版本否则触发重roll
if (hasSwipes) {
const currentIndex = currentSwipeId[lastAiMessage.floor] !== undefined
? currentSwipeId[lastAiMessage.floor]
: lastAiMessage.swipe_id;
if (currentIndex < lastAiMessage.swipes.length - 1) {
handleSwipeChange(lastAiMessage.floor, 1);
} else {
// 已在最后一个版本触发重roll在目标消息的swipe数组中添加新版本
console.log('[ChatBox] 已在最后一个版本触发重roll添加swipe');
handleRerollMessage(lastAiMessage);
}
} else {
// 没有 swipe直接触发重roll在目标消息的swipe数组中添加新版本
console.log('[ChatBox] 没有 swipe触发重roll添加swipe');
handleRerollMessage(lastAiMessage);
}
}
};
window.addEventListener('keydown', handleGlobalKeyDown);
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
}, [messages, currentSwipeId]);
// 处理编辑消息
const handleEdit = (message) => {
setEditingId(message.floor);
setEditContent(message.mes);
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
};
// 保存编辑
const handleSaveEdit = (messageId) => {
// 调用 store 中的 updateMessage 方法
updateMessage(messageId, editContent);
setEditingId(null);
setEditContent('');
cancelEditing(); // ✅ 使用 store 方法
};
// 取消编辑
const handleCancelEdit = () => {
setEditingId(null);
setEditContent('');
cancelEditing(); // ✅ 使用 store 方法
};
// 新增处理swipe切换
@@ -140,17 +318,176 @@ const ChatBox = () => {
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < message.swipes.length) {
setCurrentSwipeId(prev => ({
...prev,
[messageId]: newIndex
}));
// ✅ 合并更新,保留其他楼层的 swipe 状态
setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex });
} else if (direction === 1 && newIndex >= message.swipes.length) {
// ✅ 当尝试切换到超出最后一个版本时触发重roll添加新swipe
console.log('[ChatBox] 右键点击最后一个版本触发重roll');
handleRerollMessage(message);
}
}
};
// ✅ 新增:右键菜单处理
const handleContextMenu = (e, message) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
message: message
});
};
// ✅ 关闭右键菜单
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, message: null });
};
// ✅ 复制消息内容
const handleCopyMessage = async (message) => {
try {
await navigator.clipboard.writeText(message.mes);
console.log('[ChatBox] 消息已复制到剪贴板');
closeContextMenu();
} catch (err) {
console.error('[ChatBox] 复制失败:', err);
alert('复制失败');
}
};
// ✅ 删除消息
const handleDeleteMessage = async (message) => {
if (!confirm(`确定要删除这条消息吗?`)) {
closeContextMenu();
return;
}
try {
const { deleteMessage } = useChatBoxStore.getState();
await deleteMessage(message.floor);
console.log('[ChatBox] 消息已删除');
closeContextMenu();
} catch (err) {
console.error('[ChatBox] 删除失败:', err);
alert('删除失败');
}
};
// ✅ 从右键菜单编辑
const handleEditFromContext = (message) => {
startEditing(message.floor, message.mes);
closeContextMenu();
};
// ✅ 重roll消息重新生成- 在目标消息的swipe数组中添加新版本
const handleRerollMessage = async (message) => {
// 只有 AI 消息才能重roll
if (message.is_user) {
closeContextMenu();
return;
}
try {
const { currentRole, currentChat, wsConnection } = useChatBoxStore.getState();
if (!currentRole || !currentChat) {
alert('请先选择角色和聊天');
closeContextMenu();
return;
}
// 获取上一条用户消息
const messageIndex = messages.findIndex(m => m.floor === message.floor);
let userMessage = null;
// 向前查找最近的用户消息
for (let i = messageIndex - 1; i >= 0; i--) {
if (messages[i].is_user) {
userMessage = messages[i];
break;
}
}
if (!userMessage) {
alert('找不到上一条用户消息');
closeContextMenu();
return;
}
console.log('[ChatBox] 重roll消息使用用户输入:', userMessage.mes);
console.log('[ChatBox] 目标消息 floor:', message.floor);
// 关闭菜单
closeContextMenu();
// ✅ 调用 sendMessage传入 targetFloor 参数在目标消息的swipe数组中添加新版本
const { sendMessage } = useChatBoxStore.getState();
await sendMessage(userMessage.mes, message.floor); // ✅ 传入 targetFloor
} catch (err) {
console.error('[ChatBox] 重roll失败:', err);
alert('重roll失败: ' + err.message);
}
};
// ✅ 分支功能:创建新的聊天记录
const handleBranchChat = async (message) => {
try {
const { currentRole, currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
if (!currentRole || !currentChat) {
alert('请先选择角色和聊天');
closeContextMenu();
return;
}
if (!confirm(`确定要从楼层 #${message.floor} 创建分支吗?\n这将复制该楼层及之前的所有内容到一个新的聊天记录。`)) {
closeContextMenu();
return;
}
console.log('[ChatBox] 创建分支,目标楼层:', message.floor);
// 调用后端 API 创建分支
const response = await fetch(
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/branch`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
target_floor: message.floor
})
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '创建分支失败');
}
const result = await response.json();
console.log('[ChatBox] 分支创建成功:', result);
// 关闭菜单
closeContextMenu();
// 切换到新的聊天记录
setChatBoxRoleAndChat(currentRole, result.new_chat_name);
alert(`分支创建成功!\n新聊天: ${result.new_chat_name}\n消息数: ${result.message_count}`);
} catch (err) {
console.error('[ChatBox] 创建分支失败:', err);
alert('创建分支失败: ' + err.message);
}
};
// 切换选项显示
const toggleOptionsPanel = () => {
setShowOptions(!showOptions);
toggleOptions(); // ✅ 使用 store 方法
};
// 打开聊天选择器
@@ -169,10 +506,10 @@ const ChatBox = () => {
if (!response.ok) throw new Error('获取聊天列表失败');
const chats = await response.json();
setCharacterChats(chats);
setCharacterChats(chats); // ✅ 使用 store 方法
// 显示聊天选择器弹窗,让用户手动选择
setShowChatSelector(true);
toggleChatSelector(); // ✅ 使用 store 方法
} catch (error) {
console.error('获取聊天列表失败:', error);
alert('获取聊天列表失败: ' + error.message);
@@ -182,16 +519,13 @@ const ChatBox = () => {
// 选择聊天并加载
const handleSelectChat = async (chatName) => {
try {
const { setChatBoxRoleAndChat, fetchChatHistory, currentRole } = useChatBoxStore.getState();
const { setChatBoxRoleAndChat, currentRole } = useChatBoxStore.getState();
// 设置当前角色和聊天
// 设置当前角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
setChatBoxRoleAndChat(currentRole, chatName);
// 加载聊天历史
await fetchChatHistory(currentRole, chatName);
// 关闭选择器
setShowChatSelector(false);
setShowChatSelector(false); // ✅ 使用 store 方法
console.log(`已切换到聊天: ${chatName}`);
} catch (error) {
@@ -200,8 +534,78 @@ const ChatBox = () => {
}
};
// ✅ 新增:删除聊天
const handleDeleteChat = async (chatName, e) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发选择聊天
if (!confirm(`确定要删除聊天 "${chatName}" 吗?此操作不可恢复!`)) {
return;
}
try {
const { currentRole } = useChatBoxStore.getState();
const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(chatName)}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('删除聊天失败');
}
console.log(`[ChatBox] 已删除聊天: ${chatName}`);
// 重新获取聊天列表
const chatsResponse = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`);
if (chatsResponse.ok) {
const chats = await chatsResponse.json();
setCharacterChats(chats);
}
// 如果删除的是当前聊天,清空当前聊天
const { currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
if (currentChat === chatName) {
setChatBoxRoleAndChat(currentRole, null);
}
alert('聊天已删除');
} catch (error) {
console.error('[ChatBox] 删除聊天失败:', error);
alert('删除聊天失败: ' + error.message);
}
};
// 新建聊天
const handleCreateNewChat = async () => {
try {
const { currentRole, createChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
if (!currentRole) {
alert('请先选择一个角色');
return;
}
// 使用当前时间戳作为聊天名称
const chatName = `chat_${Date.now()}`;
// 创建新聊天
await createChat(currentRole, chatName);
// 切换到新创建的聊天
setChatBoxRoleAndChat(currentRole, chatName);
// 关闭选择器
setShowChatSelector(false);
console.log(`已创建并切换到新聊天: ${chatName}`);
} catch (error) {
console.error('创建聊天失败:', error);
alert('创建聊天失败: ' + error.message);
}
};
// 渲染单条消息
const renderMessage = (message) => {
const renderMessage = (message, index) => {
const isUser = message.is_user;
const isEditing = editingId === message.floor;
@@ -231,29 +635,54 @@ const ChatBox = () => {
}
}
// ✅ 使用 id 或组合 key 确保唯一性
const uniqueKey = message.id || `${message.floor}-${index}`;
// ✅ 判断是否为最后一条消息(最后一楼)
const isLastMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
// 计算消息深度(从最新消息开始计数)
const messageDepth = messages.length > 0 ? messages[messages.length - 1].floor - message.floor : 0;
// ✅ 使用预处理后的消息内容(已应用 markdownOnly 正则)
const displayMes = processedMessages[message.floor] || currentMes;
return (
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
<div
key={uniqueKey}
className={`message ${isUser ? 'user' : 'ai'}`}
onContextMenu={(e) => handleContextMenu(e, message)} // 添加右键菜单
>
{/* ✅ Swipe 控制按钮 - 仅在最后一楼显示 */}
{hasSwipes && !isUser && isLastMessage && (
<div className="swipe-controls-absolute">
<button
className="swipe-button-side"
onClick={() => handleSwipeChange(message.floor, -1)}
disabled={currentSwipeIndex === 0}
title="上一个版本"
>
</button>
<span className="swipe-counter-side" title={`${message.swipes.length} 个版本`}>
{currentSwipeIndex + 1}/{message.swipes.length}
</span>
<button
className="swipe-button-side"
onClick={() => handleSwipeChange(message.floor, 1)}
disabled={false} // 始终启用最后一个版本时点击会触发重roll
title={currentSwipeIndex === message.swipes.length - 1 ? "生成新版本重roll" : "下一个版本"}
>
</button>
</div>
)}
<div className="message-container">
<div className="message-header">
<span className="message-name">{displayName}</span>
<span className="message-id">#{message.floor}</span>
<div className="message-toolbar">
<div className="toolbar-buttons">
<button
className="toolbar-button"
onClick={() => handleEdit(message)}
title="编辑"
>
</button>
<button
className="toolbar-button"
title="更多"
>
</button>
</div>
</div>
{/* ✅ 移除工具栏按钮,改用右键菜单 */}
</div>
<div className="message-content">
{isEditing ? (
@@ -261,7 +690,7 @@ const ChatBox = () => {
<textarea
className="edit-textarea"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onChange={(e) => updateEditContent(e.target.value)}
/>
<div className="edit-buttons">
<button
@@ -280,33 +709,25 @@ const ChatBox = () => {
</div>
) : (
<div className="bubble">
{options.htmlRender && !isUser ? (
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
) : options.markdownRender ? (
<MarkdownRenderer content={currentMes} />
{options.renderMode === 'html' && !isUser ? (
// ✅ HTML 渲染:只在检测到 HTML 标签时使用 dangerouslySetInnerHTML
// ✅ 如果是纯文本,使用 plain-text 模式显示(保留原始格式)
(() => {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(displayMes);
if (hasHtmlTags) {
// 如果包含 HTML 标签,直接渲染
return <div dangerouslySetInnerHTML={{ __html: displayMes }} />;
} else {
// 如果是纯文本,使用 pre-wrap 保留换行和空格
return <div className="plain-text">{displayMes}</div>;
}
})()
) : options.renderMode === 'markdown' ? (
// ✅ 使用 displayMes已应用 markdownOnly 正则)
<MarkdownRenderer content={displayMes} />
) : (
<div className="plain-text">{currentMes}</div>
)}
{hasSwipes && isLatestMessage && !isUser && (
<div className="swipe-controls">
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, -1)}
disabled={currentSwipeIndex === 0}
>
</button>
<span className="swipe-counter">
{currentSwipeIndex + 1}/{message.swipes.length}
</span>
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, 1)}
disabled={currentSwipeIndex === message.swipes.length - 1}
>
</button>
</div>
// ✅ 纯文本模式
<div className="plain-text">{displayMes}</div>
)}
</div>
)}
@@ -318,7 +739,7 @@ const ChatBox = () => {
return (
<div className="chat-box">
<div className="chat-messages">
<div className="chat-messages" ref={messagesContainerRef}>
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
@@ -336,30 +757,25 @@ const ChatBox = () => {
<button
className={`options-toggle ${showOptions ? 'active' : ''}`}
title="Toggle Options"
onClick={() => setShowOptions(!showOptions)}
onClick={toggleOptions} // 使用 store 方法
>
{showOptions ? '×' : '≡'}
</button>
{showOptions && (
<div className="chat-options">
<label className="option-checkbox">
<input
type="checkbox"
checked={options.markdownRender}
onChange={() => toggleOption('markdownRender')}
/>
<span className="checkmark"></span>
<span className="option-label">Markdown渲染</span>
</label>
<label className="option-checkbox">
<input
type="checkbox"
checked={options.htmlRender}
onChange={() => toggleOption('htmlRender')}
/>
<span className="checkmark"></span>
<span className="option-label">HTML渲染</span>
</label>
{/* 渲染模式切换按钮 */}
<div className="option-group-title">显示</div>
<button
className="render-mode-btn"
onClick={() => cycleRenderMode()}
title={`当前: ${options.renderMode === 'none' ? '纯文本' : options.renderMode === 'html' ? 'HTML' : 'Markdown'},点击切换`}
>
{options.renderMode === 'none' ? '📄 纯文本' :
options.renderMode === 'html' ? '🌐 HTML' :
'📝 Markdown'}
</button>
<div className="option-group-title">功能</div>
<label className="option-checkbox">
<input
type="checkbox"
@@ -379,6 +795,17 @@ const ChatBox = () => {
<span className="option-label">动态表格</span>
</label>
{/* 自动掷骰子替换 */}
<label className="option-checkbox">
<input
type="checkbox"
checked={options.autoDiceRoll}
onChange={() => toggleOption('autoDiceRoll')}
/>
<span className="checkmark"></span>
<span className="option-label">🎲 自动掷骰子</span>
</label>
{/* 生图工作流选项 */}
<label className="option-checkbox">
<input
@@ -413,10 +840,6 @@ const ChatBox = () => {
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
style={{
height: `${inputHeight}px`,
overflowY: inputHeight >= 300 ? 'auto' : 'hidden'
}}
placeholder="Type your message..."
/>
</div>
@@ -450,11 +873,21 @@ const ChatBox = () => {
</div>
<div className="chat-selector-body">
{/* 新建聊天按钮 */}
<div className="chat-selector-actions">
<button
className="new-chat-btn"
onClick={handleCreateNewChat}
>
+ 新建聊天
</button>
</div>
{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
@@ -468,6 +901,14 @@ const ChatBox = () => {
</div>
)}
</div>
{/* ✅ 删除按钮 */}
<button
className="chat-delete-btn"
onClick={(e) => handleDeleteChat(chat.chat_name, e)}
title="删除此聊天"
>
🗑
</button>
</div>
))}
</div>
@@ -480,6 +921,50 @@ const ChatBox = () => {
</div>
</div>
)}
{/* ✅ 右键菜单 */}
{contextMenu.visible && contextMenu.message && (
<div
className="context-menu-overlay"
onClick={closeContextMenu}
>
<div
className="context-menu"
ref={contextMenuRef}
style={{
left: `${contextMenu.x}px`,
top: `${contextMenu.y}px`
}}
onClick={(e) => e.stopPropagation()}
>
<div className="context-menu-item" onClick={() => handleEditFromContext(contextMenu.message)}>
<span className="menu-icon"></span>
<span className="menu-label">编辑</span>
</div>
<div className="context-menu-item" onClick={() => handleCopyMessage(contextMenu.message)}>
<span className="menu-icon">📋</span>
<span className="menu-label">复制</span>
</div>
{/* ✅ 只有 AI 消息才显示重roll选项 */}
{!contextMenu.message.is_user && (
<div className="context-menu-item" onClick={() => handleRerollMessage(contextMenu.message)}>
<span className="menu-icon">🔄</span>
<span className="menu-label">重roll</span>
</div>
)}
{/* ✅ 分支功能 - 所有消息都可以分支 */}
<div className="context-menu-item" onClick={() => handleBranchChat(contextMenu.message)}>
<span className="menu-icon">🌿</span>
<span className="menu-label">分支</span>
</div>
<div className="context-menu-divider"></div>
<div className="context-menu-item danger" onClick={() => handleDeleteMessage(contextMenu.message)}>
<span className="menu-icon">🗑</span>
<span className="menu-label">删除</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -3,7 +3,7 @@
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
}
.sidebar-tabs {
@@ -47,10 +47,29 @@
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
padding: 0; /* ✅ 移除padding滚动条作为间隔 */
min-height: 0;
}
/* ✅ 美化滚动条 - 符合主题 */
.sidebar-content::-webkit-scrollbar {
width: 8px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
/* Tab placeholder for empty states */
.tab-placeholder {
padding: var(--spacing-lg);
@@ -75,3 +94,29 @@
font-size: 0.85rem;
line-height: 1.5;
}
/* ✅ 左侧边栏标签右键菜单样式 */
.sidebar-tab-context-menu {
min-width: 200px;
max-width: 280px;
}
.context-menu-header {
padding: var(--spacing-sm) var(--spacing-md);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(102, 126, 234, 0.05));
border-bottom: 1px solid var(--color-border-light);
}
.menu-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--color-accent);
}
.context-menu-description {
padding: var(--spacing-sm) var(--spacing-md);
font-size: 0.8rem;
color: var(--color-text-secondary);
line-height: 1.5;
background-color: rgba(0, 0, 0, 0.02);
}

View File

@@ -1,16 +1,66 @@
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
import React from 'react';
import React, { useRef } from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import useSideBarRightStore from '../../Store/SideBarLeft/SideBarLeftSlice';
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import Gallery from './tabs/Gallery';
import CharacterCard from './tabs/CharacterCard';
import ApiConfig from './tabs/ApiConfig';
import Presets from './tabs/Presets';
import WorldBook from './tabs/WorldBook';
import TokenUsage from './tabs/TokenUsage/TokenUsage';
const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
const { fetchPresets } = usePresetStore();
const { fetchProfiles } = useApiConfigStore();
const contextMenuRef = useRef(null);
// ✅ 右键菜单状态
const [contextMenu, setContextMenu] = React.useState({
visible: false,
x: 0,
y: 0,
tab: null
});
// 处理标签切换
const handleTabClick = (tabId) => {
setActiveTab(tabId);
// 如果切换到预设标签,刷新预设列表
if (tabId === 'presets') {
fetchPresets();
}
// 如果切换到API配置标签刷新配置文件列表
if (tabId === 'api') {
fetchProfiles();
}
};
// ✅ 显示右键菜单
const handleContextMenu = (e, tab) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
tab: tab
});
};
// ✅ 关闭右键菜单
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, tab: null });
};
// ✅ 点击菜单项后自动切换到对应标签
const handleSwitchToTab = (tabId) => {
handleTabClick(tabId);
closeContextMenu();
};
return (
<div className="sidebar-left">
@@ -19,8 +69,9 @@ const SideBarLeft = () => {
<button
key={tab.id}
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
title={tab.title}
onClick={() => handleTabClick(tab.id)}
onContextMenu={(e) => handleContextMenu(e, tab)} // 添加右键菜单
// 移除 title改用右键菜单显示详情
>
{tab.label}
</button>
@@ -33,7 +84,69 @@ const SideBarLeft = () => {
{activeTab === 'api' && <ApiConfig />}
{activeTab === 'presets' && <Presets />}
{activeTab === 'worldbook' && <WorldBook />}
{activeTab === 'tokenUsage' && <TokenUsage />}
</div>
{/* ✅ 右键菜单 */}
{contextMenu.visible && contextMenu.tab && (
<div
className="context-menu-overlay"
onClick={closeContextMenu}
>
<div
className="context-menu sidebar-tab-context-menu"
ref={contextMenuRef}
style={{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }}
onClick={(e) => e.stopPropagation()}
>
{/* 菜单标题 - 显示标签名称 */}
<div className="context-menu-header">
<span className="menu-title">{contextMenu.tab.label}</span>
</div>
{/* 菜单描述 */}
<div className="context-menu-description">
{contextMenu.tab.title}
</div>
<div className="context-menu-divider"></div>
{/* 操作项 */}
<div className="context-menu-item" onClick={() => handleSwitchToTab(contextMenu.tab.id)}>
<span className="menu-icon">📂</span>
<span className="menu-label">打开</span>
</div>
{/* 根据标签类型显示不同的快捷操作 */}
{contextMenu.tab.id === 'character' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('character'); setTimeout(() => document.querySelector('.create-character-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建角色</span>
</div>
</>
)}
{contextMenu.tab.id === 'presets' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('presets'); setTimeout(() => document.querySelector('.create-preset-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建预设</span>
</div>
</>
)}
{contextMenu.tab.id === 'worldbook' && (
<>
<div className="context-menu-item" onClick={() => { handleSwitchToTab('worldbook'); setTimeout(() => document.querySelector('.action-btn')?.click(), 100); }}>
<span className="menu-icon"></span>
<span className="menu-label">新建世界书</span>
</div>
</>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
/* ApiConfig - Compact Modern Design */
.api-config-container {
padding: var(--spacing-md);
padding: 0; /* sidebar-content已有padding这里不需要 */
color: var(--color-text-primary);
height: 100%;
overflow-y: auto;
@@ -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

@@ -77,22 +77,12 @@ const ApiConfig = () => {
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 跟踪用户是否正在编辑 API Key用于控制脱敏显示
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到API分页
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
fetchProfiles();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
if (selectedProfileId) {
@@ -100,6 +90,15 @@ const ApiConfig = () => {
}
}, [selectedProfileId]);
// 当 profiles 加载完成后,如果没有选中的配置文件,则自动加载第一个
useEffect(() => {
if (profiles.length > 0 && !selectedProfileId && activeTab === 'api') {
// 尝试加载 activeMap 中记录的配置文件,如果没有则加载第一个
const defaultProfileId = activeMap[currentCategory] || profiles[0].id;
setSelectedProfileId(defaultProfileId);
}
}, [profiles, selectedProfileId, activeTab, currentCategory, activeMap]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
@@ -123,6 +122,11 @@ const ApiConfig = () => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;
// 如果是 API Key 字段,标记为正在编辑
if (name === 'apiKey') {
setIsEditingApiKey(true);
}
if (path) {
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
setFormData(prev => {
@@ -267,8 +271,10 @@ const ApiConfig = () => {
return;
}
if (!currentApi.apiKey) {
alert('请先填写 API 密钥');
// 检查 API Key可以为空如果已保存过后端会使用保存的 key
// 但如果既没有输入 key也没有保存过则会失败
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
alert('请先填写 API 密钥,或先保存配置文件');
return;
}
@@ -277,6 +283,7 @@ const ApiConfig = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: selectedProfileId || null, // 使用 id 字段传递 profileId
apiUrl: currentApi.apiUrl,
apiKey: currentApi.apiKey,
category: currentCategory,
@@ -388,19 +395,9 @@ const ApiConfig = () => {
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
await deleteProfile(selectedProfileId);
setSelectedProfileId('');
handleCreateNew();
}
};
// 处理设为默认配置
const handleSetActive = async () => {
if (!selectedProfileId) {
alert('请先保存配置文件');
return;
}
await setActiveConfig(currentCategory, selectedProfileId);
};
// 配置区域标签(简短名称 + Tooltip
const configTabs = [
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
@@ -409,8 +406,13 @@ const ApiConfig = () => {
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
];
// 判断当前 API 密钥是否是脱敏的
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
// 脱敏 API Key 显示只显示最后6位
const maskApiKey = (apiKey) => {
if (!apiKey || apiKey.length <= 6) {
return apiKey || '';
}
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
};
// 计算有修改的 API 数量
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
@@ -438,40 +440,37 @@ const ApiConfig = () => {
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置文件...</option>
{profiles.length === 0 ? (
<option value="">暂无配置文件</option>
) : (
<>
<option value="">-- 选择配置文件 --</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</>
)}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
title="创建新的配置文件"
>
+ 新建
</button>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[currentCategory] === selectedProfileId}
>
设为默认
</button>
{selectedProfileId && (
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
title="删除当前配置文件"
>
删除
</button>
</>
)}
</div>
</div>
@@ -606,13 +605,31 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="cloud-apiKey">API Key</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="cloud-apiKey"
value={formData.imageModel.cloud.apiKey}
value={isEditingApiKey ? formData.imageModel.cloud.apiKey : maskApiKey(formData.imageModel.cloud.apiKey)}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
placeholder="sk-..."
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData.imageModel.cloud.apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
imageModel: {
...prev.imageModel,
cloud: {
...prev.imageModel.cloud,
apiKey: ''
}
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-group">
@@ -668,17 +685,29 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData[currentCategory].apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
apiKey: ''
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-row">

View File

@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 6px;
padding: 0; /* sidebar-content已有padding这里不需要 */
gap: 6px;
background: var(--color-bg-primary);
overflow-y: auto;
@@ -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;
@@ -69,6 +76,9 @@
cursor: pointer;
font-size: 10px;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 2px;
}
.tag-btn:hover {
@@ -76,12 +86,36 @@
color: var(--color-accent);
}
.tag-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
/* 包含模式(第一次点击)- 绿色 */
.tag-btn.tag-include {
background: #10b981;
border-color: #10b981;
color: white;
}
.tag-btn.tag-include:hover {
background: #059669;
border-color: #059669;
}
/* 排除模式(第二次点击)- 红色 */
.tag-btn.tag-exclude {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.tag-btn.tag-exclude:hover {
background: #dc2626;
border-color: #dc2626;
}
/* 排除前缀符号 */
.tag-prefix {
font-weight: bold;
font-size: 12px;
}
/* 错误和加载提示 */
.error-message,
.loading-message,
@@ -101,17 +135,18 @@
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 6px;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度让窄屏也能显示2列 */
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
padding: 2px;
align-content: start; /* ✅ 从上到下排列,不留空白 */
}
.character-item {
position: relative;
display: flex;
flex-direction: column;
width: 140px; /* 固定宽度 */
height: 200px; /* 固定高度 */
width: 100%; /* 改为自适应宽度 */
height: 170px; /* 固定高度,不随空间浮动 */
background: var(--color-bg-secondary);
border: 2px solid var(--color-border);
border-radius: 6px;
@@ -126,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);
@@ -140,7 +188,7 @@
/* 角色头像 */
.character-avatar-wrapper {
width: 100%;
aspect-ratio: 3/4;
aspect-ratio: 3/4; /* 保持3:4比例 */
position: relative;
background: var(--color-bg-tertiary);
overflow: hidden;
@@ -170,24 +218,24 @@
/* 角色信息 */
.character-info {
padding: 6px;
padding: 5px; /* 减小padding从6px降到5px */
flex: 1;
min-height: 50px;
min-height: 40px; /* 减小最小高度从50px降到40px */
/* 移除 user-select因为已经在父元素设置了 */
}
.character-name {
font-size: 12px;
font-size: 11px; /* 稍微减小字体从12px降到11px */
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 3px;
margin-bottom: 2px; /* 减小间距 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.character-desc {
font-size: 10px;
font-size: 9px; /* 减小字体从10px降到9px */
color: var(--color-text-muted);
line-height: 1.3;
display: -webkit-box;
@@ -200,7 +248,7 @@
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 3px;
margin-top: 2px; /* 减小间距从3px降到2px */
}
.tag {
@@ -270,39 +318,117 @@
flex-direction: column;
gap: 8px;
padding: 8px;
position: relative; /* 为悬浮工具栏提供定位上下文 */
}
.edit-header {
/* 悬浮工具栏 */
.floating-toolbar {
position: sticky;
top: 0;
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
margin-bottom: 8px;
}
/* 工具栏内容(默认隐藏) */
.toolbar-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding: 6px 10px;
background: var(--color-bg-tertiary);
border-radius: 4px;
border-radius: 6px;
border: 1px solid var(--color-border-light);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
/* 默认状态:收缩 */
max-height: 0;
opacity: 0;
overflow: hidden;
transform: scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 悬停时展开 */
.floating-toolbar:hover .toolbar-content {
max-height: 60px;
opacity: 1;
transform: scale(1);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0; /* 允许收缩 */
}
.edit-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-actions {
/* 小球指示器 */
.toolbar-indicator {
position: absolute;
right: 10px;
top: 50%;
margin-top: -18px; /* 使用负 margin 代替 transform确保点击区域和视觉位置一致 */
width: 36px;
height: 36px;
background: var(--color-accent);
border-radius: 50%;
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
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);
z-index: 10;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
50% {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.floating-toolbar:hover .toolbar-indicator {
opacity: 0;
transform: scale(0);
pointer-events: none; /* ✅ 消失时不阻挡点击 */
}
.indicator-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.toolbar-actions {
display: flex;
gap: 4px;
align-items: center;
}
.export-format-selector {
padding: 4px 8px;
padding: 3px 6px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s ease;
transition: all 0.15s ease;
}
.export-format-selector:hover {
@@ -314,55 +440,78 @@
border-color: var(--color-accent);
}
.edit-btn {
padding: 4px 10px;
.toolbar-btn {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
}
.edit-btn.save {
.toolbar-btn:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.toolbar-btn.save {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.edit-btn.save:hover {
.toolbar-btn.save:hover {
background: var(--color-accent-dark);
border-color: var(--color-accent-dark);
}
.edit-btn.export {
.toolbar-btn.export {
background: #10b981;
border-color: #10b981;
color: white;
}
.edit-btn.export:hover {
.toolbar-btn.export:hover {
background: #059669;
border-color: #059669;
}
.edit-btn.delete {
/* ✅ 复制按钮样式 */
.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;
color: white;
}
.edit-btn.delete:hover {
.toolbar-btn.delete:hover {
background: #dc2626;
border-color: #dc2626;
}
.edit-btn.cancel {
.toolbar-btn.cancel {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
}
.edit-btn.cancel:hover {
.toolbar-btn.cancel:hover {
background: var(--color-error);
border-color: var(--color-error);
color: white;
@@ -407,6 +556,68 @@
.form-group textarea {
resize: vertical;
min-height: 60px;
max-height: 300px; /* 设置最大高度 */
overflow-y: auto;
transition: height 0.2s ease; /* 平滑过渡 */
}
.form-hint {
font-size: 10px;
color: var(--color-text-muted);
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;
}
/* 聊天选择器弹窗 */
@@ -420,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

@@ -1,11 +1,13 @@
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
import { useCharacterStore } from '../../../../Store/SideBarLeft';
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice'; // ✅ 引入侧边栏 Store
import './CharacterCard.css';
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
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';
@@ -24,7 +26,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"
@@ -54,8 +56,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
)}
{character.tags && character.tags.length > 0 && (
<div className="character-tags">
{character.tags.slice(0, 3).map(tag => (
<span key={tag} className="tag">{tag}</span>
{character.tags.slice(0, 3).map((tag, index) => (
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
))}
</div>
)}
@@ -65,7 +67,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';
@@ -76,30 +79,45 @@ const CharacterCard = () => {
selectedCharacter,
isLoading,
error,
currentPage,
pageSize,
characterChats,
fetchCharacters,
selectCharacter,
deleteCharacter,
exportCharacterAsPng,
setCurrentPage,
setPageSize,
getCurrentPageCharacters,
getTotalPages,
fetchCharacterChats
} = useCharacterStore();
const [filterTag, setFilterTag] = useState('');
const [isEditing, setIsEditing] = useState(false); // 是否处于编辑模式
const [editForm, setEditForm] = useState(null); // 编辑表单数据
// ✅ 从 CharacterCardUIStore 获取 UI 状态
const {
filterTags,
isEditing,
editForm,
currentPage,
pageSize,
toggleFilterTag,
removeFilterTag,
clearAllFilters,
startEditing,
cancelEditing,
updateEditForm,
setCurrentPage,
setPageSize,
nextPage,
prevPage
} = useCharacterCardUIStore();
// 引入世界书 Store
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
const [showImages, setShowImages] = useState(true); // ✅ 是否显示图片
const fileInputRef = useRef(null);
const clickTimeoutRef = useRef(null);
// 加载角色列表
// 加载角色列表和世界书列表
useEffect(() => {
fetchCharacters();
fetchWorldBooks(); // 加载世界书列表
// 清理函数
return () => {
@@ -112,16 +130,37 @@ const CharacterCard = () => {
// 获取所有唯一的 tags
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
// 过滤角色
const filteredCharacters = filterTag
? characters.filter(char => (char.tags || []).includes(filterTag))
: characters;
// 过滤角色(支持多标签交集筛选)
const filteredCharacters = React.useMemo(() => {
if (filterTags.length === 0) return characters;
// 获取当前页的角色数据
const currentPageCharacters = getCurrentPageCharacters();
// 分离包含和排除标签
const includeTags = filterTags
.filter(f => f.startsWith('include:'))
.map(f => f.substring(8));
// 获取总页数
const totalPages = getTotalPages();
const excludeTags = filterTags
.filter(f => f.startsWith('exclude:'))
.map(f => f.substring(8));
return characters.filter(char => {
const charTags = char.tags || [];
// 检查是否包含所有必须的标签(交集)
const hasAllInclude = includeTags.every(tag => charTags.includes(tag));
// 检查是否不包含所有排除的标签
const hasNoExclude = excludeTags.every(tag => !charTags.includes(tag));
return hasAllInclude && hasNoExclude;
});
}, [characters, filterTags]);
// ✅ 计算当前页和总页数
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentPageCharacters = filteredCharacters.slice(startIndex, endIndex);
// 处理导入文件
const handleImport = async (event) => {
@@ -151,6 +190,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({
@@ -161,7 +221,8 @@ const CharacterCard = () => {
first_mes: '',
mes_example: '',
categories: [],
tags: []
tags: [],
historyMode: mode // ✅ 添加历史记录模式
});
} catch (err) {
console.error('创建失败:', err);
@@ -176,6 +237,12 @@ const CharacterCard = () => {
try {
await deleteCharacter(name);
// ✅ 删除成功后自动切换到角色分页
const { setActiveTab } = useSideBarLeftStore.getState();
setActiveTab('character');
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
} catch (err) {
console.error('删除失败:', err);
}
@@ -231,6 +298,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);
@@ -247,17 +370,7 @@ const CharacterCard = () => {
selectCharacter(character);
// 进入编辑模式
setIsEditing(true);
setEditForm({
name: character.name,
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 || []
});
startEditing(character); // ✅ 使用 store 方法
// 获取该角色的聊天列表,并自动切换到最后一个聊天
try {
@@ -267,38 +380,19 @@ const CharacterCard = () => {
if (chats.length > 0) {
// 有聊天记录,切换到最后一个
const lastChat = chats[chats.length - 1].chat_name;
// 先设置角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
} else {
// 没有聊天记录,自动创建一个默认聊天
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,创建默认聊天...`);
// 没有聊天记录,只设置角色,不创建聊天文件
// 聊天文件将在用户发送第一条消息时创建
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,等待用户发送第一条消息...`);
const defaultChatName = '默认聊天';
try {
const createResponse = await fetch(`/api/chat/${encodeURIComponent(character.name)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_name: defaultChatName,
metadata: {
user_name: 'User',
character_name: character.name
}
})
});
if (createResponse.ok || createResponse.status === 400) {
// 400 表示聊天已存在,也可以直接使用
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, defaultChatName);
console.log(`[CharacterCard] 已为角色 ${character.name} 设置聊天: ${defaultChatName}`);
} else {
console.error('[CharacterCard] 创建默认聊天失败:', await createResponse.text());
}
} catch (error) {
console.error('[CharacterCard] 创建聊天异常:', error);
}
// 只设置角色不设置聊天currentChat 为 null
// ChatBox 会显示开场白(临时消息),但不会保存到文件
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, null);
}
}
} catch (error) {
@@ -312,8 +406,7 @@ const CharacterCard = () => {
// 退出编辑模式
const handleCancelEdit = () => {
setIsEditing(false);
setEditForm(null);
cancelEditing(); // ✅ 使用 store 方法
};
// 保存编辑
@@ -323,8 +416,7 @@ const CharacterCard = () => {
try {
const { updateCharacter } = useCharacterStore.getState();
await updateCharacter(selectedCharacter.name, editForm);
setIsEditing(false);
setEditForm(null);
cancelEditing(); // ✅ 使用 store 方法
} catch (err) {
console.error('保存失败:', err);
alert('保存失败: ' + err.message);
@@ -333,10 +425,14 @@ const CharacterCard = () => {
// 处理表单字段变化
const handleFormChange = (field, value) => {
setEditForm(prev => ({
...prev,
[field]: value
}));
updateEditForm(field, value); // ✅ 使用 store 方法
};
// 自动调整 textarea 高度
const autoResizeTextarea = (e) => {
const textarea = e.target;
textarea.style.height = 'auto'; // 重置高度
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度最大300px
};
return (
@@ -350,6 +446,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"
@@ -363,20 +467,93 @@ const CharacterCard = () => {
{allTags.length > 0 && (
<div className="tag-filter">
<button
className={`tag-btn ${!filterTag ? 'active' : ''}`}
onClick={() => setFilterTag('')}
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
onClick={clearAllFilters}
>
全部
</button>
{allTags.map(tag => (
{allTags.map(tag => {
// 判断当前标签的状态
let status = 'none'; // none, include, exclude
const includeFilter = `include:${tag}`;
const excludeFilter = `exclude:${tag}`;
if (filterTags.includes(includeFilter)) {
status = 'include';
} else if (filterTags.includes(excludeFilter)) {
status = 'exclude';
}
return (
<button
key={tag}
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
onClick={() => setFilterTag(tag)}
className={`tag-btn tag-${status}`}
onClick={() => toggleFilterTag(tag)}
title={
status === 'include' ? '包含该标签(再次点击排除)' :
status === 'exclude' ? '排除该标签(再次点击取消)' :
'筛选包含该标签的角色'
}
>
{status === 'exclude' && <span className="tag-prefix">¬</span>}
{tag}
</button>
))}
);
})}
</div>
)}
{/* 当前筛选条件显示 */}
{filterTags.length > 0 && (
<div className="active-filters" style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '4px 6px',
alignItems: 'center'
}}>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)', marginRight: '4px' }}>
筛选中:
</span>
{filterTags.map((filter) => {
const mode = filter.startsWith('include:') ? 'include' : 'exclude';
const tag = filter.substring(filter.indexOf(':') + 1);
return (
<span
key={`filter-${mode}-${tag}`}
className={`active-filter-tag active-filter-${mode}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '10px',
background: mode === 'include' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
border: `1px solid ${mode === 'include' ? '#10b981' : '#ef4444'}`,
color: mode === 'include' ? '#10b981' : '#ef4444'
}}
>
{mode === 'exclude' && '¬'}
{tag}
<button
onClick={() => removeFilterTag(tag)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: '0 2px',
fontSize: '12px',
lineHeight: 1
}}
title="移除筛选"
>
×
</button>
</span>
);
})}
</div>
)}
@@ -397,21 +574,47 @@ const CharacterCard = () => {
{/* 编辑模式 */}
{isEditing && selectedCharacter && editForm && (
<div className="character-edit-panel">
<div className="edit-header">
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
<div className="edit-actions">
{/* 悬浮工具栏 */}
<div className="floating-toolbar">
<div className="toolbar-content">
<div className="toolbar-left">
<span className="edit-title">编辑: {selectedCharacter.name}</span>
</div>
<div className="toolbar-actions">
<select
className="export-format-selector"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
title="选择导出格式"
>
<option value="png">🖼 图片</option>
<option value="png">🖼 PNG</option>
<option value="json">📄 JSON</option>
</select>
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑 删除</button>
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
<button className="edit-btn cancel" onClick={handleCancelEdit}> 取消</button>
{/* ✅ 复制角色按钮 */}
<button
className="toolbar-btn duplicate"
onClick={() => handleDuplicate(selectedCharacter)}
title="复制角色"
>
📋
</button>
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
📤
</button>
<button className="toolbar-btn delete" onClick={() => handleDelete(selectedCharacter.name)} title="删除角色">
🗑
</button>
<button className="toolbar-btn save" onClick={handleSaveEdit} title="保存更改">
💾
</button>
<button className="toolbar-btn cancel" onClick={handleCancelEdit} title="返回列表">
</button>
</div>
</div>
{/* 小球指示器 */}
<div className="toolbar-indicator">
<span className="indicator-icon"></span>
</div>
</div>
@@ -424,13 +627,18 @@ const CharacterCard = () => {
onChange={(e) => handleFormChange('name', e.target.value)}
placeholder="角色名称"
/>
<small className="form-hint">💡 提示修改角色名将同步重命名文件夹和所有相关引用</small>
</div>
<div className="form-group">
<label>描述</label>
<textarea
value={editForm.description}
onChange={(e) => handleFormChange('description', e.target.value)}
onChange={(e) => {
handleFormChange('description', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色描述"
rows={6}
/>
@@ -440,7 +648,11 @@ const CharacterCard = () => {
<label>性格</label>
<textarea
value={editForm.personality}
onChange={(e) => handleFormChange('personality', e.target.value)}
onChange={(e) => {
handleFormChange('personality', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色性格"
rows={3}
/>
@@ -450,7 +662,11 @@ const CharacterCard = () => {
<label>场景</label>
<textarea
value={editForm.scenario}
onChange={(e) => handleFormChange('scenario', e.target.value)}
onChange={(e) => {
handleFormChange('scenario', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="场景设定"
rows={3}
/>
@@ -460,7 +676,11 @@ const CharacterCard = () => {
<label>开场白</label>
<textarea
value={editForm.first_mes}
onChange={(e) => handleFormChange('first_mes', e.target.value)}
onChange={(e) => {
handleFormChange('first_mes', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="第一条消息"
rows={4}
/>
@@ -470,7 +690,11 @@ const CharacterCard = () => {
<label>对话示例</label>
<textarea
value={editForm.mes_example}
onChange={(e) => handleFormChange('mes_example', e.target.value)}
onChange={(e) => {
handleFormChange('mes_example', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="对话示例"
rows={4}
/>
@@ -485,6 +709,151 @@ const CharacterCard = () => {
placeholder="标签1, 标签2, 标签3"
/>
</div>
<div className="form-group">
<label>绑定世界书</label>
<select
value={editForm.worldInfoId || ''}
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
>
<option key="none" value="">不绑定</option>
{worldBooks.map(book => (
<option key={`worldbook-${book.id}`} value={book.id}>
{book.name}
</option>
))}
</select>
<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 key="full" value="full">全量模式保留所有消息</option>
<option key="summary" value="summary">总结模式定期总结历史</option>
<option key="rag" 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
type="text"
value={Object.entries(editForm.tableDefaults || {}).map(([k, v]) => `${k}:${v}`).join(';')}
onChange={(e) => {
// 解析格式key1:value1;key2:value2,key3:value3
// 支持分隔符:分号、逗号
const entries = e.target.value
.split(/[;,]/)
.map(pair => pair.trim())
.filter(pair => pair !== '')
.map(pair => {
const [key, ...valueParts] = pair.split(':');
const value = valueParts.join(':').trim(); // 支持值中包含冒号
return { key: key.trim(), value };
})
.filter(({ key, value }) => key && value !== undefined);
// 构建 tableDefaults 对象
const tableDefaults = {};
const tableHeaders = [];
entries.forEach(({ key, value }) => {
tableHeaders.push(key);
// 尝试转换为数字
const numValue = Number(value);
tableDefaults[key] = isNaN(numValue) ? value : numValue;
});
handleFormChange('tableHeaders', tableHeaders);
handleFormChange('tableDefaults', tableDefaults);
}}
placeholder="力量:80;敏捷:65;智力:90;HP:100"
/>
<small className="form-hint">格式key:value用分号或逗号分隔例如力量:80;敏捷:65,HP:100</small>
</div>
</div>
</div>
)}
@@ -495,7 +864,7 @@ const CharacterCard = () => {
<div className="character-list">
{currentPageCharacters.length === 0 ? (
<div className="empty-message">
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
</div>
) : (
currentPageCharacters.map(char => (
@@ -504,6 +873,7 @@ const CharacterCard = () => {
character={char}
isSelected={selectedCharacter?.id === char.id}
onSelect={handleSelectCharacter}
showImages={showImages}
/>
))
)}
@@ -514,7 +884,7 @@ const CharacterCard = () => {
<div className="pagination-controls">
<button
className="pagination-btn"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
onClick={prevPage} // 使用 store 方法
disabled={currentPage === 1}
>
上一页
@@ -526,7 +896,7 @@ const CharacterCard = () => {
<button
className="pagination-btn"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
onClick={nextPage} // 使用 store 方法
disabled={currentPage === totalPages}
>
下一页
@@ -535,12 +905,12 @@ const CharacterCard = () => {
<select
className="page-size-selector"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
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>
)}

Some files were not shown because too many files have changed in this diff Show More