Compare commits
7 Commits
fix/世界书读取出
...
d6745b45a5
| Author | SHA1 | Date | |
|---|---|---|---|
| d6745b45a5 | |||
| 9faccc2c03 | |||
| f843a74715 | |||
| 44df56c8d2 | |||
| adb59da06d | |||
| 2050a30a52 | |||
| 7fc9e10c99 |
5
.env
5
.env
@@ -9,8 +9,3 @@ REGEX_FILE=/data/regex_rules.json
|
|||||||
COMFYUI_API_URL=http://comfyui:8188
|
COMFYUI_API_URL=http://comfyui:8188
|
||||||
BACKEND_PORT=8000
|
BACKEND_PORT=8000
|
||||||
FRONTEND_PORT=8501
|
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
23
.env.example
Normal 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
157
.gitignore
vendored
@@ -29,7 +29,21 @@ env/
|
|||||||
.venv
|
.venv
|
||||||
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/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
@@ -38,14 +52,39 @@ VENV/
|
|||||||
.project
|
.project
|
||||||
.pydevproject
|
.pydevproject
|
||||||
.settings/
|
.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
|
.DS_Store
|
||||||
Thumbs.db
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
$RECYCLE.BIN/
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows thumbnails cache files
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
dm thumbs.db
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
# ==================== Node.js ====================
|
# ==================== Node.js ====================
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -67,26 +106,80 @@ frontend/dist-ssr/
|
|||||||
!.env.development
|
!.env.development
|
||||||
!.env.production
|
!.env.production
|
||||||
|
|
||||||
|
# ⚠️ 敏感信息:API 配置文件(包含 API Keys)
|
||||||
|
data/apiconfig/*.json
|
||||||
|
|
||||||
# ==================== Logs ====================
|
# ==================== Logs ====================
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
log/
|
log/
|
||||||
|
|
||||||
# ==================== Data files ====================
|
# ==================== Data files ====================
|
||||||
# 保留目录结构,忽略数据文件
|
# ⚠️ 所有用户数据文件都不应该提交到版本控制
|
||||||
data/chat/**/*.jsonl
|
|
||||||
data/chat/**/*.json
|
# 聊天记录(包含聊天历史和消息数据)
|
||||||
data/preset/*.json
|
data/chat/
|
||||||
data/worldbooks/*.json
|
data/chat/**/*
|
||||||
data/apiconfig/*.json
|
|
||||||
data/comfyui_workflows/*.json
|
# 角色卡数据(角色配置和头像)
|
||||||
data/images/*
|
data/characters/
|
||||||
data/temp/*
|
data/characters/**/*
|
||||||
outputs/*
|
data/avatars/
|
||||||
imports/*
|
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 ====================
|
# ==================== Docker ====================
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# ==================== Temporary files ====================
|
# ==================== Temporary files ====================
|
||||||
*.tmp
|
*.tmp
|
||||||
@@ -101,6 +194,13 @@ coverage/
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.tox/
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
*.cover
|
||||||
|
*.cover.gz
|
||||||
|
|
||||||
# ==================== Misc ====================
|
# ==================== Misc ====================
|
||||||
.parcel-cache/
|
.parcel-cache/
|
||||||
@@ -112,6 +212,19 @@ htmlcov/
|
|||||||
.dynamodb/
|
.dynamodb/
|
||||||
.tern-port
|
.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 ====================
|
# ==================== Project specific ====================
|
||||||
# Backend output
|
# Backend output
|
||||||
backend/__pycache__/
|
backend/__pycache__/
|
||||||
@@ -120,11 +233,27 @@ backend/api/routes/__pycache__/
|
|||||||
backend/core/__pycache__/
|
backend/core/__pycache__/
|
||||||
backend/services/__pycache__/
|
backend/services/__pycache__/
|
||||||
backend/utils/__pycache__/
|
backend/utils/__pycache__/
|
||||||
|
backend/models/__pycache__/
|
||||||
|
|
||||||
# Claude settings
|
# Claude settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Lingma cache
|
||||||
|
.lingma/
|
||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.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.*
|
||||||
|
|||||||
@@ -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
559
README.md
@@ -1,166 +1,131 @@
|
|||||||
# LLM Workflow Engine
|
# 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. **启动服务**
|
- Python 3.11+
|
||||||
```bash
|
- Node.js 18+
|
||||||
# 构建并启动所有服务
|
- Docker & Docker Compose(可选)
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本地开发
|
### 本地开发
|
||||||
|
|
||||||
如果你想分别运行前后端进行开发:
|
#### 1. 克隆项目
|
||||||
|
|
||||||
#### 后端开发
|
|
||||||
|
|
||||||
1. **安装 Python 依赖**
|
|
||||||
```bash
|
```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
|
cd backend
|
||||||
pip install -r requirements.txt
|
python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **启动后端服务**
|
后端服务将在 `http://localhost:23338` 启动。
|
||||||
```bash
|
|
||||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 前端开发
|
#### 3. 前端启动
|
||||||
|
|
||||||
1. **安装 Node.js 依赖**
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **启动前端开发服务器**
|
# 安装依赖
|
||||||
```bash
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **访问应用**
|
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
||||||
- 前端界面: http://localhost:5173
|
|
||||||
- 确保后端在 http://localhost:8000 运行
|
|
||||||
|
|
||||||
## 🔧 配置说明
|
### Docker 部署
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
#### 前端环境变量 (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 命令参考
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建并启动
|
# 一键启动
|
||||||
docker-compose up --build
|
|
||||||
|
|
||||||
# 后台运行
|
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
@@ -168,59 +133,319 @@ docker-compose logs -f
|
|||||||
|
|
||||||
# 停止服务
|
# 停止服务
|
||||||
docker-compose down
|
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 Output(AI 输出)
|
||||||
|
- `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
|
```bash
|
||||||
# 类型检查
|
# 检查后端是否运行
|
||||||
npm run type-check
|
curl http://localhost:23338/api/health
|
||||||
|
|
||||||
# 构建
|
# 检查前端代理配置
|
||||||
npm run build
|
cat frontend/vite.config.js
|
||||||
|
|
||||||
# 预览生产构建
|
|
||||||
npm run preview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 后端
|
### 2. API Key 不生效
|
||||||
|
|
||||||
|
**问题**: 配置了 API Key 但仍然无法调用 LLM
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
1. 检查 API 配置文件是否存在:`data/apiconfig/`
|
||||||
|
2. 检查加密密钥是否正确:`.env` 中的 `FERNET_KEY`
|
||||||
|
3. 重启后端服务
|
||||||
|
|
||||||
|
### 3. Docker 部署后无法访问
|
||||||
|
|
||||||
|
**问题**: `docker-compose up` 后无法访问服务
|
||||||
|
|
||||||
|
**解决**:
|
||||||
```bash
|
```bash
|
||||||
# 运行测试(如果有的话)
|
# 查看容器状态
|
||||||
cd backend
|
docker-compose ps
|
||||||
pytest
|
|
||||||
|
|
||||||
# 代码格式化
|
# 查看日志
|
||||||
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
|
||||||
|
|||||||
@@ -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组件而非浏览器原生弹窗。
|
|
||||||
@@ -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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**祝测试顺利!** 🎉
|
|
||||||
@@ -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**
|
|
||||||
|
|
||||||
- 下拉框正确读取所有世界书
|
|
||||||
- 分页参数正确传递
|
|
||||||
- 数据格式匹配
|
|
||||||
- 错误处理完善
|
|
||||||
- 用户体验流畅
|
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
from fastapi import APIRouter
|
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 utils.file_utils import get_all_roles_and_chats
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 注册子路由
|
# 注册子路由(HTTP路由)
|
||||||
router.include_router(presetsRoute.router)
|
router.include_router(presetsRoute.router)
|
||||||
router.include_router(chatsRoute.router)
|
router.include_router(chatsRoute.router)
|
||||||
router.include_router(worldbooksRoute.router)
|
router.include_router(worldbooksRoute.router)
|
||||||
router.include_router(apiConfigRoute.router)
|
router.include_router(apiConfigRoute.router)
|
||||||
router.include_router(charactersRoute.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")
|
@router.get("/tool_bar/get_all_role_and_chat")
|
||||||
|
|||||||
@@ -2,24 +2,28 @@ from fastapi import APIRouter, HTTPException, UploadFile, File
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Dict, Optional, List, Any
|
from typing import Dict, Optional, List, Any
|
||||||
import json
|
import json
|
||||||
import os
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
import base64
|
|
||||||
from services.comfyui_workflow_manager import workflow_manager
|
from services.comfyui_workflow_manager import workflow_manager
|
||||||
from services.llm_model_service import LLMModelService
|
from services.llm_model_service import LLMModelService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
|
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 = Path(settings.DATA_PATH) / "apiconfig"
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
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):
|
class ApiConfigItem(BaseModel):
|
||||||
"""单个 API 配置项"""
|
"""单个 API 配置项"""
|
||||||
@@ -50,32 +54,8 @@ class ProfileResponse(BaseModel):
|
|||||||
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
|
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]:
|
def load_profile(profile_id: str) -> Optional[dict]:
|
||||||
"""加载配置文件"""
|
"""加载配置文件"""
|
||||||
@@ -119,29 +99,28 @@ def get_all_profiles():
|
|||||||
|
|
||||||
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||||
def get_profile(profile_id: str):
|
def get_profile(profile_id: str):
|
||||||
"""获取单个配置文件(API Key 已脱敏)"""
|
"""获取单个配置文件(明文存储,不返回 API Key)"""
|
||||||
profile = load_profile(profile_id)
|
profile = load_profile(profile_id)
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(status_code=404, detail="配置文件不存在")
|
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||||
|
|
||||||
# 脱敏所有 API Key
|
# 移除 API Key 字段,不返回给前端
|
||||||
masked_apis = {}
|
safe_apis = {}
|
||||||
for category, api_config in profile.get("apis", {}).items():
|
for category, api_config in profile.get("apis", {}).items():
|
||||||
masked_config = api_config.copy()
|
safe_config = api_config.copy()
|
||||||
if "apiKey" in masked_config and masked_config["apiKey"]:
|
safe_config.pop("apiKey", None)
|
||||||
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
|
safe_apis[category] = safe_config
|
||||||
masked_apis[category] = masked_config
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": profile.get("id", profile_id),
|
"id": profile.get("id", profile_id),
|
||||||
"name": profile.get("name", profile_id),
|
"name": profile.get("name", profile_id),
|
||||||
"apis": masked_apis
|
"apis": safe_apis
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/profiles", response_model=ProfileResponse)
|
@router.post("/profiles", response_model=ProfileResponse)
|
||||||
def create_or_update_profile(request: ProfileSaveRequest):
|
def create_or_update_profile(request: ProfileSaveRequest):
|
||||||
"""创建或更新配置文件(增量更新)"""
|
"""创建或更新配置文件(增量更新,明文存储 API Key)"""
|
||||||
# 加载现有配置
|
# 加载现有配置
|
||||||
existing_profile = load_profile(request.profileId)
|
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():
|
for category, api_config in request.apis.items():
|
||||||
api_config_dict = api_config.dict(exclude_none=True)
|
api_config_dict = api_config.dict(exclude_none=True)
|
||||||
|
|
||||||
# 处理 API Key 加密
|
# 如果前端传入了空的 apiKey,保留原有的 key
|
||||||
if api_config.apiKey and api_config.apiKey != "****":
|
if api_config.apiKey == "" and category in existing_profile.get("apis", {}):
|
||||||
# 如果是新的明文 key,加密它
|
existing_key = existing_profile["apis"][category].get("apiKey", "")
|
||||||
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
|
if existing_key:
|
||||||
elif api_config.apiKey == "****":
|
api_config_dict["apiKey"] = existing_key
|
||||||
# 如果是脱敏的 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)
|
|
||||||
|
|
||||||
# 更新配置
|
# 更新配置
|
||||||
if "apis" not in existing_profile:
|
if "apis" not in existing_profile:
|
||||||
@@ -177,28 +151,25 @@ def create_or_update_profile(request: ProfileSaveRequest):
|
|||||||
"apis": {}
|
"apis": {}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 添加所有 API 配置
|
# 添加所有 API 配置(明文存储)
|
||||||
for category, api_config in request.apis.items():
|
for category, api_config in request.apis.items():
|
||||||
api_config_dict = api_config.dict(exclude_none=True)
|
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
|
profile_data["apis"][category] = api_config_dict
|
||||||
|
|
||||||
# 保存配置文件
|
# 保存配置文件
|
||||||
save_profile(request.profileId, profile_data)
|
save_profile(request.profileId, profile_data)
|
||||||
|
|
||||||
# 返回脱敏后的数据
|
# 返回不包含 API Key 的数据
|
||||||
masked_apis = {}
|
safe_apis = {}
|
||||||
for category, api_config in profile_data.get("apis", {}).items():
|
for category, api_config in profile_data.get("apis", {}).items():
|
||||||
masked_config = api_config.copy()
|
safe_config = api_config.copy()
|
||||||
if "apiKey" in masked_config and masked_config["apiKey"]:
|
safe_config.pop("apiKey", None)
|
||||||
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
|
safe_apis[category] = safe_config
|
||||||
masked_apis[category] = masked_config
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": profile_data.get("id", request.profileId),
|
"id": profile_data.get("id", request.profileId),
|
||||||
"name": profile_data.get("name", 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):
|
def test_connection(api_config: ApiConfigItem):
|
||||||
"""测试 API 连接并获取模型列表"""
|
"""测试 API 连接并获取模型列表"""
|
||||||
try:
|
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)
|
provider = LLMModelService.detect_provider(api_config.apiUrl)
|
||||||
|
|
||||||
# 获取模型列表
|
# 获取模型列表
|
||||||
models = LLMModelService.get_models_by_provider(
|
models = LLMModelService.get_models_by_provider(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
api_key=api_config.apiKey or "",
|
api_key=api_key_to_use,
|
||||||
api_url=api_config.apiUrl
|
api_url=api_config.apiUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
154
backend/api/routes/chatSummaryRoute.py
Normal file
154
backend/api/routes/chatSummaryRoute.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
聊天总结 API 路由
|
||||||
|
|
||||||
|
处理聊天记录的总结请求
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Body
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from services.chat_service import chat_service
|
||||||
|
from services.chat_summary_service import chat_summary_service
|
||||||
|
from models.internal import SummaryConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chats", tags=["chat-summary"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{role_name}/{chat_name}/summarize")
|
||||||
|
async def summarize_chat_history(
|
||||||
|
role_name: str,
|
||||||
|
chat_name: str,
|
||||||
|
request_data: Dict[str, Any] = Body(...)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
总结聊天历史记录
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
request_data: {
|
||||||
|
"startFloor": int, # 总结起始楼层
|
||||||
|
"endFloor": int, # 总结结束楼层
|
||||||
|
"summaryConfig": {...}, # 总结配置
|
||||||
|
"apiConfig": {...} # API配置
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"success": bool,
|
||||||
|
"summaryText": str, # 总结文本
|
||||||
|
"startFloor": int,
|
||||||
|
"endFloor": int,
|
||||||
|
"message": str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 提取请求参数
|
||||||
|
start_floor = request_data.get("startFloor")
|
||||||
|
end_floor = request_data.get("endFloor")
|
||||||
|
summary_config_data = request_data.get("summaryConfig", {})
|
||||||
|
api_config = request_data.get("apiConfig", {})
|
||||||
|
|
||||||
|
if not start_floor or not end_floor:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
|
||||||
|
|
||||||
|
# 2. 加载聊天记录
|
||||||
|
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||||
|
if not chat_log:
|
||||||
|
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
|
||||||
|
|
||||||
|
messages = chat_log.messages
|
||||||
|
total_messages = len(messages)
|
||||||
|
|
||||||
|
# 3. 验证楼层范围
|
||||||
|
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 构建SummaryConfig对象
|
||||||
|
summary_config = SummaryConfig(**summary_config_data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
|
||||||
|
f"楼层范围: {start_floor}-{end_floor}, "
|
||||||
|
f"包含用户输入: {summary_config.includeUserInput}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 调用总结服务
|
||||||
|
summary_text = await chat_summary_service.summarize_messages(
|
||||||
|
messages=messages,
|
||||||
|
start_floor=start_floor,
|
||||||
|
end_floor=end_floor,
|
||||||
|
summary_config=summary_config,
|
||||||
|
api_config=api_config
|
||||||
|
)
|
||||||
|
|
||||||
|
if not summary_text:
|
||||||
|
raise HTTPException(status_code=500, detail="总结生成失败")
|
||||||
|
|
||||||
|
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
|
||||||
|
|
||||||
|
# 6. 更新聊天记录(清空原文 + 替换总结)
|
||||||
|
chat_service.summarize_chat_messages(
|
||||||
|
role_name=role_name,
|
||||||
|
chat_name=chat_name,
|
||||||
|
start_floor=start_floor,
|
||||||
|
end_floor=end_floor,
|
||||||
|
summary_text=summary_text
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"summaryText": summary_text,
|
||||||
|
"startFloor": start_floor,
|
||||||
|
"endFloor": end_floor,
|
||||||
|
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{role_name}/{chat_name}/summary-status")
|
||||||
|
async def get_summary_status(role_name: str, chat_name: str):
|
||||||
|
"""
|
||||||
|
获取聊天总结状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"historyMode": str,
|
||||||
|
"summaryCounter": int,
|
||||||
|
"lastSummaryFloor": int,
|
||||||
|
"summaryConfig": {...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||||
|
if not chat_log:
|
||||||
|
raise HTTPException(status_code=404, detail="聊天记录不存在")
|
||||||
|
|
||||||
|
header = chat_log.header
|
||||||
|
|
||||||
|
return {
|
||||||
|
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
|
||||||
|
"summaryCounter": header.summaryCounter or 0,
|
||||||
|
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
|
||||||
|
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
512
backend/api/routes/chatWsRoute.py
Normal file
512
backend/api/routes/chatWsRoute.py
Normal 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()
|
||||||
|
# 不抛出异常,避免影响主流程
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from backend.services.chat_service import ChatService
|
from backend.services.chat_service import ChatService
|
||||||
from backend.core.config import settings
|
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")
|
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
||||||
chat_service = ChatService(data_path)
|
chat_service = ChatService(data_path)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
async def list_all_chats():
|
async def list_all_chats():
|
||||||
"""获取所有角色的所有聊天列表"""
|
"""获取所有角色的所有聊天列表"""
|
||||||
return chat_service.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}")
|
@router.get("/{role_name}")
|
||||||
async def list_role_chats(role_name: str):
|
async def list_role_chats(role_name: str):
|
||||||
"""获取指定角色的所有聊天列表"""
|
"""获取指定角色的所有聊天列表"""
|
||||||
@@ -30,13 +43,6 @@ async def list_role_chats(role_name: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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)
|
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_chat(role_name: str, chat_data: dict):
|
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:
|
except FileExistsError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{role_name}/{chat_name}")
|
@router.put("/{role_name}/{chat_name}")
|
||||||
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||||
"""更新聊天元数据"""
|
"""更新聊天元数据"""
|
||||||
# TODO: 实现更新聊天元数据功能
|
# TODO: 实现更新聊天元数据功能
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{role_name}/{chat_name}")
|
@router.delete("/{role_name}/{chat_name}")
|
||||||
async def delete_chat(role_name: str, chat_name: str):
|
async def delete_chat(role_name: str, chat_name: str):
|
||||||
"""删除指定聊天"""
|
"""删除指定聊天"""
|
||||||
# TODO: 实现删除聊天功能
|
# TODO: 实现删除聊天功能
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{role_name}/{chat_name}/messages")
|
@router.get("/{role_name}/{chat_name}/messages")
|
||||||
async def list_messages(role_name: str, chat_name: str):
|
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:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def get_message(role_name: str, chat_name: str, floor: int):
|
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:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
@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):
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
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:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
async def delete_message(role_name: str, chat_name: str, floor: int):
|
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))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(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)}")
|
||||||
|
|||||||
140
backend/api/routes/imageGalleryRoute.py
Normal file
140
backend/api/routes/imageGalleryRoute.py
Normal 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))
|
||||||
@@ -1,37 +1,26 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
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 = APIRouter(prefix="/presets", tags=["presets"])
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=dict)
|
||||||
async def list_presets():
|
async def list_presets():
|
||||||
"""获取所有预设列表及其基本信息"""
|
"""获取所有预设列表及其基本信息"""
|
||||||
# return await PresetService.list_all_presets()
|
try:
|
||||||
return {"presets": []}
|
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):
|
@router.get("/{preset_name}/components/{component_id}")
|
||||||
"""获取指定预设的完整内容"""
|
async def get_preset_component(preset_name: str, component_id: 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):
|
|
||||||
"""删除指定预设"""
|
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
@router.get("/{preset_name}/components")
|
@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")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
@router.get("/{preset_name}/components/{component_id}")
|
@router.get("/{preset_name}")
|
||||||
async def get_preset_component(preset_name: str, component_id: str):
|
async def get_preset(preset_name: str):
|
||||||
"""获取指定组件的详情"""
|
"""获取指定预设的完整内容"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
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)
|
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||||
async def add_preset_component(preset_name: str, component_data: dict):
|
async def add_preset_component(preset_name: str, component_data: dict):
|
||||||
@@ -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):
|
async def delete_preset_component(preset_name: str, component_id: str):
|
||||||
"""从预设中删除指定组件"""
|
"""从预设中删除指定组件"""
|
||||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.post("/{preset_name}/reorder")
|
||||||
|
async def reorder_preset_components(preset_name: str, order_data: dict):
|
||||||
|
"""重新排序预设组件"""
|
||||||
|
try:
|
||||||
|
component_order = order_data.get("component_order", [])
|
||||||
|
if not component_order:
|
||||||
|
raise HTTPException(status_code=400, detail="component_order is required")
|
||||||
|
|
||||||
|
updated_preset = PresetService.reorder_components(preset_name, component_order)
|
||||||
|
return {"success": True, "preset": updated_preset}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
387
backend/api/routes/regexRoute.py
Normal file
387
backend/api/routes/regexRoute.py
Normal 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))
|
||||||
130
backend/api/routes/tokenUsageRoute.py
Normal file
130
backend/api/routes/tokenUsageRoute.py
Normal 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}
|
||||||
@@ -38,6 +38,80 @@ async def list_worldbooks():
|
|||||||
logger.error(f"Failed to list worldbooks: {str(e)}")
|
logger.error(f"Failed to list worldbooks: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=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])
|
@router.get("/{name}", response_model=Dict[str, Any])
|
||||||
async def get_worldbook(name: str):
|
async def get_worldbook(name: str):
|
||||||
"""
|
"""
|
||||||
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=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))
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 1. 动态计算项目根目录
|
# 1. 动态计算项目根目录
|
||||||
# 假设 config.py 位于 backend/core/ 目录下
|
# 在 Docker 环境中:config.py 位于 /app/core/,需要向上2级到 /app/
|
||||||
# __file__ 指向本文件的绝对路径
|
# 在本地开发中:config.py 位于 backend/core/,需要向上3级到项目根目录
|
||||||
# .parent 指向 backend/core/ 目录
|
_config_path = Path(__file__).resolve()
|
||||||
# .parent.parent 指向 backend/ 目录
|
if _config_path.parent.parent.name == 'app':
|
||||||
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
# Docker 环境:/app/core/config.py -> /app/
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
PROJECT_ROOT = _config_path.parent.parent
|
||||||
|
else:
|
||||||
|
# 本地开发:backend/core/config.py -> 项目根目录
|
||||||
|
PROJECT_ROOT = _config_path.parent.parent.parent
|
||||||
|
|
||||||
# 2. 加载 .env 文件
|
# 2. 加载 .env 文件
|
||||||
# 假设 .env 文件位于项目根目录下
|
# 假设 .env 文件位于项目根目录下
|
||||||
@@ -16,13 +19,6 @@ load_dotenv(PROJECT_ROOT / ".env")
|
|||||||
|
|
||||||
|
|
||||||
class Settings:
|
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
|
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
|
||||||
@@ -35,7 +31,8 @@ class Settings:
|
|||||||
STATE_FILE = DATA_PATH / "state.json"
|
STATE_FILE = DATA_PATH / "state.json"
|
||||||
SCHEMA_FILE = DATA_PATH / "schema.json"
|
SCHEMA_FILE = DATA_PATH / "schema.json"
|
||||||
PRESETS_FILE = DATA_PATH / "presets.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"
|
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
||||||
|
|
||||||
# --- 业务数据目录 ---
|
# --- 业务数据目录 ---
|
||||||
@@ -46,17 +43,20 @@ class Settings:
|
|||||||
# 预设目录
|
# 预设目录
|
||||||
PRESET_PATH = DATA_PATH / "preset"
|
PRESET_PATH = DATA_PATH / "preset"
|
||||||
|
|
||||||
# 聊天记录目录
|
# 聊天记录目录(同时存放角色卡和聊天)
|
||||||
CHAT_PATH = DATA_PATH / "chat"
|
CHAT_PATH = DATA_PATH / "chat"
|
||||||
|
|
||||||
|
# 兼容别名:用于代码中引用
|
||||||
|
CHATS_PATH = CHAT_PATH
|
||||||
|
|
||||||
# 临时文件目录
|
# 临时文件目录
|
||||||
TEMP_PATH = DATA_PATH / "temp"
|
TEMP_PATH = DATA_PATH / "temp"
|
||||||
|
|
||||||
# ComfyUI 工作流目录
|
# ComfyUI 工作流目录
|
||||||
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
||||||
|
|
||||||
# 角色卡目录
|
# 角色卡目录(已合并到 CHAT_PATH)
|
||||||
CHARACTERS_PATH = DATA_PATH / "characters"
|
CHARACTERS_PATH = CHAT_PATH
|
||||||
|
|
||||||
# 图片资源目录
|
# 图片资源目录
|
||||||
IMAGES_PATH = DATA_PATH / "images"
|
IMAGES_PATH = DATA_PATH / "images"
|
||||||
@@ -76,6 +76,10 @@ class Settings:
|
|||||||
for directory in directories:
|
for directory in directories:
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
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()
|
settings = Settings()
|
||||||
|
|
||||||
|
|||||||
@@ -136,11 +136,23 @@ class CharacterCard(BaseModel):
|
|||||||
first_mes: str = Field(..., description="首条开场消息")
|
first_mes: str = Field(..., description="首条开场消息")
|
||||||
mes_example: str = Field(..., description="对话示例")
|
mes_example: str = Field(..., description="对话示例")
|
||||||
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
||||||
|
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (SillyTavern 关键字机制)")
|
||||||
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
||||||
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
||||||
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
||||||
alternate_greetings: Optional[List[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="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||||
@@ -150,18 +162,54 @@ class CharacterCard(BaseModel):
|
|||||||
|
|
||||||
# ==================== 聊天记录 (Chat Log) ====================
|
# ==================== 聊天记录 (Chat Log) ====================
|
||||||
|
|
||||||
|
# 历史记录模式枚举
|
||||||
|
class HistoryMode(str, Enum):
|
||||||
|
"""
|
||||||
|
历史记录处理模式
|
||||||
|
|
||||||
|
- FULL: 全量模式,保留所有消息(需经正则处理)
|
||||||
|
- SUMMARY: 总结模式,定期用LLM总结历史消息
|
||||||
|
- RAG: RAG模式,基于向量检索(暂不实现)
|
||||||
|
"""
|
||||||
|
FULL = 'full' # 全量模式
|
||||||
|
SUMMARY = 'summary' # 总结模式
|
||||||
|
RAG = 'rag' # RAG模式(预留)
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
总结配置
|
||||||
|
|
||||||
|
用于控制历史消息的总结行为
|
||||||
|
"""
|
||||||
|
enabled: bool = Field(True, description="是否启用总结")
|
||||||
|
interval: int = Field(10, ge=2, description="总结间隔(每隔多少条消息总结一次)")
|
||||||
|
includeUserInput: bool = Field(True, description="总结时是否包含用户输入")
|
||||||
|
summaryPrompt: str = Field(
|
||||||
|
"请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||||
|
description="总结提示词"
|
||||||
|
)
|
||||||
|
maxSummaryLength: int = Field(500, ge=100, description="总结文本的最大长度(字符数)")
|
||||||
|
|
||||||
|
|
||||||
class ChatHeader(BaseModel):
|
class ChatHeader(BaseModel):
|
||||||
"""
|
"""
|
||||||
项目内部聊天记录头
|
项目内部聊天记录头
|
||||||
|
|
||||||
包含聊天的元数据,如参与角色、创建时间等。
|
包含聊天的元数据,如参与角色、创建时间等。
|
||||||
"""
|
"""
|
||||||
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||||
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||||
characterId: str = Field(..., description="关联的角色卡 ID")
|
characterId: str = Field(..., description="关联的角色卡 ID")
|
||||||
userName: str = Field("User", description="用户角色名")
|
userName: str = Field("User", description="用户角色名")
|
||||||
characterName: str = Field(..., description="AI 角色名称")
|
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="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
messageCount: int = Field(0, description="消息数量")
|
messageCount: int = Field(0, description="消息数量")
|
||||||
@@ -172,7 +220,7 @@ class ChatMessage(BaseModel):
|
|||||||
"""
|
"""
|
||||||
项目内部聊天消息
|
项目内部聊天消息
|
||||||
|
|
||||||
单条对话消息,支持多版本 (swipes)、token 统计等功能。
|
单条对话消息,支持多版本 (swipes)、token 统计、历史记录总结等功能。
|
||||||
"""
|
"""
|
||||||
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||||
name: str = Field(..., description="发送者名称")
|
name: str = Field(..., description="发送者名称")
|
||||||
@@ -186,6 +234,11 @@ class ChatMessage(BaseModel):
|
|||||||
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||||
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
||||||
|
|
||||||
|
# ✅ 历史记录总结相关字段
|
||||||
|
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
|
||||||
|
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
|
||||||
|
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
|
||||||
|
|
||||||
|
|
||||||
class ChatLog(BaseModel):
|
class ChatLog(BaseModel):
|
||||||
"""
|
"""
|
||||||
@@ -299,3 +352,84 @@ class ChatRAGConfig(BaseModel):
|
|||||||
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
|
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
|
||||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 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="创建时间戳")
|
||||||
|
|||||||
164
backend/models/regex_rules.py
Normal file
164
backend/models/regex_rules.py
Normal 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))
|
||||||
76
backend/models/summary_message.py
Normal file
76
backend/models/summary_message.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
聊天总结消息数据模型
|
||||||
|
|
||||||
|
用于存储总结后的历史消息记录
|
||||||
|
"""
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryMessage(BaseModel):
|
||||||
|
"""
|
||||||
|
总结消息
|
||||||
|
|
||||||
|
保存总结后的文本和元数据
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
|
||||||
|
chatId: str = Field(..., description="关联的聊天 ID")
|
||||||
|
|
||||||
|
# 总结内容
|
||||||
|
summaryText: str = Field(..., description="总结后的文本内容")
|
||||||
|
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
|
||||||
|
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
|
||||||
|
messageCount: int = Field(..., description="被总结的消息数量")
|
||||||
|
includeUserInput: bool = Field(True, description="是否包含用户输入")
|
||||||
|
|
||||||
|
# 统计信息
|
||||||
|
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
|
||||||
|
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
|
||||||
|
|
||||||
|
# 版本控制
|
||||||
|
version: int = Field(1, description="总结版本号(用于追溯)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_summary(
|
||||||
|
cls,
|
||||||
|
chat_id: str,
|
||||||
|
summary_text: str,
|
||||||
|
message_ids: List[str],
|
||||||
|
include_user_input: bool = True,
|
||||||
|
version: int = 1
|
||||||
|
) -> 'SummaryMessage':
|
||||||
|
"""
|
||||||
|
创建总结消息的工厂方法
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: 聊天 ID
|
||||||
|
summary_text: 总结文本
|
||||||
|
message_ids: 被总结的消息 ID 列表
|
||||||
|
include_user_input: 是否包含用户输入
|
||||||
|
version: 版本号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SummaryMessage 实例
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# 生成消息范围描述
|
||||||
|
if len(message_ids) > 0:
|
||||||
|
message_range = f"{len(message_ids)}条消息"
|
||||||
|
else:
|
||||||
|
message_range = "无消息"
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
chatId=chat_id,
|
||||||
|
summaryText=summary_text,
|
||||||
|
originalMessageIds=message_ids,
|
||||||
|
messageRange=message_range,
|
||||||
|
messageCount=len(message_ids),
|
||||||
|
includeUserInput=include_user_input,
|
||||||
|
version=version
|
||||||
|
)
|
||||||
49
backend/models/system_settings.py
Normal file
49
backend/models/system_settings.py
Normal 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))
|
||||||
@@ -6,6 +6,7 @@ requests>=2.31.0
|
|||||||
|
|
||||||
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
||||||
langchain>=0.1.0
|
langchain>=0.1.0
|
||||||
|
langchain-core>=0.1.0
|
||||||
langchain-openai>=0.0.5
|
langchain-openai>=0.0.5
|
||||||
langchain-anthropic>=0.1.1
|
langchain-anthropic>=0.1.1
|
||||||
openai>=1.12.0
|
openai>=1.12.0
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
|
|
||||||
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
||||||
"""
|
"""
|
||||||
from .prompt_assembler import PromptAssembler, PromptConfig
|
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
|
||||||
|
# 需要使用时请显式导入,例如:from services.preset_service import PresetService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = []
|
||||||
'PromptAssembler',
|
|
||||||
'PromptConfig',
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class CharacterCardConverter:
|
|||||||
first_mes=data.get('first_mes', ''),
|
first_mes=data.get('first_mes', ''),
|
||||||
mes_example=data.get('mes_example', ''),
|
mes_example=data.get('mes_example', ''),
|
||||||
categories=[], # ST没有categories
|
categories=[], # ST没有categories
|
||||||
|
tableHeaders=[], # ST没有tableHeaders
|
||||||
worldInfoId=extensions.get('world'),
|
worldInfoId=extensions.get('world'),
|
||||||
outputSchema=None, # ST不支持结构化输出
|
outputSchema=None, # ST不支持结构化输出
|
||||||
avatarPath=avatar_path,
|
avatarPath=avatar_path,
|
||||||
|
|||||||
@@ -95,11 +95,15 @@ class CharacterService:
|
|||||||
first_mes=data.get('first_mes', ''),
|
first_mes=data.get('first_mes', ''),
|
||||||
mes_example=data.get('mes_example', ''),
|
mes_example=data.get('mes_example', ''),
|
||||||
categories=data.get('categories', []),
|
categories=data.get('categories', []),
|
||||||
|
tags=data.get('tags', []), # ✅ 使用标签数组
|
||||||
worldInfoId=data.get('worldInfoId'),
|
worldInfoId=data.get('worldInfoId'),
|
||||||
outputSchema=data.get('outputSchema'),
|
outputSchema=data.get('outputSchema'),
|
||||||
avatarPath=avatar_path,
|
avatarPath=avatar_path,
|
||||||
alternate_greetings=data.get('alternate_greetings', []),
|
alternate_greetings=data.get('alternate_greetings', []),
|
||||||
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())),
|
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||||
lastChatAt=last_chat_at,
|
lastChatAt=last_chat_at,
|
||||||
@@ -177,8 +181,8 @@ class CharacterService:
|
|||||||
更新角色卡
|
更新角色卡
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: 角色名
|
name: 角色名(旧名称,用于定位文件夹)
|
||||||
updates: 更新的字段
|
updates: 更新的字段(可以包含 name 字段来重命名)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
更新后的 CharacterCard 对象
|
更新后的 CharacterCard 对象
|
||||||
@@ -193,6 +197,29 @@ class CharacterService:
|
|||||||
with open(char_file, 'r', encoding='utf-8') as f:
|
with open(char_file, 'r', encoding='utf-8') as f:
|
||||||
existing_data = json.load(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.update(updates)
|
||||||
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ class ChatService:
|
|||||||
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||||
continue
|
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]:
|
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -156,6 +158,7 @@ class ChatService:
|
|||||||
messages.append(msg_data)
|
messages.append(msg_data)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"header": header, # 完整的 header,包含 tableHeaders, tableDefaults, tableData
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"user_name": header.get("user_name", "User"),
|
"user_name": header.get("user_name", "User"),
|
||||||
"character_name": header.get("character_name", ""),
|
"character_name": header.get("character_name", ""),
|
||||||
@@ -169,6 +172,41 @@ class ChatService:
|
|||||||
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
raise
|
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:
|
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)
|
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
|
||||||
header = {
|
header = {
|
||||||
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||||
@@ -207,7 +260,8 @@ class ChatService:
|
|||||||
"timedWorldInfo": {},
|
"timedWorldInfo": {},
|
||||||
"variables": {},
|
"variables": {},
|
||||||
"tainted": False,
|
"tainted": False,
|
||||||
"lastInContextMessageId": -1
|
"lastInContextMessageId": -1,
|
||||||
|
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
|
||||||
}
|
}
|
||||||
|
|
||||||
# 写入header
|
# 写入header
|
||||||
@@ -381,3 +435,225 @@ class ChatService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||||
raise
|
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))
|
||||||
|
|||||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
聊天总结服务
|
||||||
|
|
||||||
|
负责调用LLM对历史消息进行总结
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from models.internal import ChatMessage, SummaryConfig
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSummaryService:
|
||||||
|
"""聊天总结服务类"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def summarize_messages(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
start_floor: int,
|
||||||
|
end_floor: int,
|
||||||
|
summary_config: SummaryConfig,
|
||||||
|
api_config: Dict[str, str]
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
对指定范围的消息进行总结
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 完整的消息列表
|
||||||
|
start_floor: 总结起始楼层(1-based)
|
||||||
|
end_floor: 总结结束楼层(1-based)
|
||||||
|
summary_config: 总结配置
|
||||||
|
api_config: API配置 {api_url, api_key, model}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
总结文本
|
||||||
|
"""
|
||||||
|
# 1. 提取需要总结的消息
|
||||||
|
messages_to_summarize = ChatSummaryService._extract_messages(
|
||||||
|
messages, start_floor, end_floor, summary_config.includeUserInput
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages_to_summarize:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# 2. 构建总结提示词
|
||||||
|
prompt = ChatSummaryService._build_summary_prompt(
|
||||||
|
messages_to_summarize, summary_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 调用LLM生成总结
|
||||||
|
summary_text = await ChatSummaryService._call_llm_for_summary(
|
||||||
|
prompt, api_config, summary_config.maxSummaryLength
|
||||||
|
)
|
||||||
|
|
||||||
|
return summary_text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_messages(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
start_floor: int,
|
||||||
|
end_floor: int,
|
||||||
|
include_user_input: bool
|
||||||
|
) -> List[ChatMessage]:
|
||||||
|
"""
|
||||||
|
提取需要总结的消息
|
||||||
|
|
||||||
|
✅ 根据用户需求:后端不筛选,全部输入给LLM
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 完整消息列表
|
||||||
|
start_floor: 起始楼层
|
||||||
|
end_floor: 结束楼层
|
||||||
|
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
需要总结的消息列表(全部消息,不做筛选)
|
||||||
|
"""
|
||||||
|
# 转换为0-based索引
|
||||||
|
start_idx = start_floor - 1
|
||||||
|
end_idx = end_floor - 1
|
||||||
|
|
||||||
|
# 提取范围内的所有消息(不筛选)
|
||||||
|
return messages[start_idx:end_idx + 1]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_summary_prompt(
|
||||||
|
messages: List[ChatMessage],
|
||||||
|
summary_config: SummaryConfig
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
构建总结提示词
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: 需要总结的消息列表
|
||||||
|
summary_config: 总结配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
完整的提示词
|
||||||
|
"""
|
||||||
|
# 使用用户自定义的总结提示词,或默认提示词
|
||||||
|
base_prompt = summary_config.summaryPrompt or (
|
||||||
|
"请总结以下对话内容,保留关键信息和上下文。"
|
||||||
|
"用简洁的语言概括主要事件、人物状态和重要细节。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建对话内容
|
||||||
|
conversation_text = "\n\n".join([
|
||||||
|
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
|
||||||
|
for msg in messages
|
||||||
|
])
|
||||||
|
|
||||||
|
# 组合完整提示词
|
||||||
|
full_prompt = f"""{base_prompt}
|
||||||
|
|
||||||
|
对话内容:
|
||||||
|
{conversation_text}
|
||||||
|
|
||||||
|
总结要求:
|
||||||
|
1. 保持简洁明了
|
||||||
|
2. 保留关键情节和设定
|
||||||
|
3. 不超过{summary_config.maxSummaryLength}字
|
||||||
|
4. 使用客观叙述语气
|
||||||
|
|
||||||
|
总结:"""
|
||||||
|
|
||||||
|
return full_prompt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _call_llm_for_summary(
|
||||||
|
prompt: str,
|
||||||
|
api_config: Dict[str, str],
|
||||||
|
max_length: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
调用LLM生成总结
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 总结提示词
|
||||||
|
api_config: API配置
|
||||||
|
max_length: 最大长度限制
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
总结文本
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 导入LLM客户端
|
||||||
|
from utils.llm_client import llm_client
|
||||||
|
|
||||||
|
# 构建消息
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 调用LLM
|
||||||
|
response = await llm_client.chat_completion(
|
||||||
|
messages=messages,
|
||||||
|
api_url=api_config.get("api_url", ""),
|
||||||
|
api_key=api_config.get("api_key", ""),
|
||||||
|
model=api_config.get("model", "gpt-3.5-turbo"),
|
||||||
|
temperature=0.3, # 总结需要较低的随机性
|
||||||
|
max_tokens=max_length,
|
||||||
|
request_timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# 提取总结文本
|
||||||
|
if isinstance(response, dict):
|
||||||
|
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
else:
|
||||||
|
summary = str(response)
|
||||||
|
|
||||||
|
# 清理和截断
|
||||||
|
summary = summary.strip()
|
||||||
|
if len(summary) > max_length:
|
||||||
|
summary = summary[:max_length] + "..."
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
|
||||||
|
# 返回降级总结(基于规则的简单摘要)
|
||||||
|
return ChatSummaryService._fallback_summary(prompt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fallback_summary(prompt: str) -> str:
|
||||||
|
"""
|
||||||
|
降级总结(当LLM调用失败时使用)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: 原始提示词
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
简单的降级总结
|
||||||
|
"""
|
||||||
|
# 提取对话中的关键信息
|
||||||
|
lines = prompt.split("\n")
|
||||||
|
user_lines = [l for l in lines if l.startswith("用户:")]
|
||||||
|
ai_lines = [l for l in lines if l.startswith("AI:")]
|
||||||
|
|
||||||
|
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
chat_summary_service = ChatSummaryService()
|
||||||
1490
backend/services/chat_workflow_service.py
Normal file
1490
backend/services/chat_workflow_service.py
Normal file
File diff suppressed because it is too large
Load Diff
346
backend/services/image_metadata_service.py
Normal file
346
backend/services/image_metadata_service.py
Normal 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()
|
||||||
281
backend/services/js_sandbox.py
Normal file
281
backend/services/js_sandbox.py
Normal 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✅ 所有测试通过!")
|
||||||
@@ -27,10 +27,21 @@ class LLMModelService:
|
|||||||
if not base_url:
|
if not base_url:
|
||||||
base_url = "https://api.openai.com/v1"
|
base_url = "https://api.openai.com/v1"
|
||||||
|
|
||||||
# 确保 base_url 以 /v1 结尾
|
# 规范化 base_url:确保有协议前缀
|
||||||
if not base_url.endswith('/v1'):
|
base_url = base_url.strip()
|
||||||
base_url = base_url.rstrip('/') + '/v1'
|
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(
|
response = requests.get(
|
||||||
f"{base_url}/models",
|
f"{base_url}/models",
|
||||||
headers={
|
headers={
|
||||||
@@ -39,6 +50,12 @@ class LLMModelService:
|
|||||||
},
|
},
|
||||||
timeout=10
|
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:
|
if response.status_code != 200:
|
||||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||||
@@ -46,14 +63,8 @@ class LLMModelService:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
models = [model['id'] for model in data.get('data', [])]
|
models = [model['id'] for model in data.get('data', [])]
|
||||||
|
|
||||||
# 过滤出聊天模型(可选)
|
# 返回所有模型,不做过滤
|
||||||
chat_models = [
|
return 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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
|
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
|
||||||
@@ -132,6 +143,9 @@ class LLMModelService:
|
|||||||
return 'anthropic'
|
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:
|
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'
|
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:
|
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
|
||||||
# SiliconFlow 等兼容 OpenAI API 的服务
|
# SiliconFlow 等兼容 OpenAI API 的服务
|
||||||
return 'openai'
|
return 'openai'
|
||||||
|
|||||||
329
backend/services/preset_service.py
Normal file
329
backend/services/preset_service.py
Normal 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
|
||||||
@@ -132,14 +132,14 @@ class PromptAssembler:
|
|||||||
|
|
||||||
# Pos 4: AN Top
|
# Pos 4: AN Top
|
||||||
for entry in grouped.get(self.POS_AN_TOP, []):
|
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||||
parts.append(entry.content)
|
parts.append(str(entry.content) if entry.content else "")
|
||||||
|
|
||||||
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||||
parts.append(f"[Author's note at depth {depth}]")
|
parts.append(f"[Author's note at depth {depth}]")
|
||||||
|
|
||||||
# Pos 5: AN Bottom
|
# Pos 5: AN Bottom
|
||||||
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
||||||
parts.append(entry.content)
|
parts.append(str(entry.content) if entry.content else "")
|
||||||
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
@@ -147,10 +147,15 @@ class PromptAssembler:
|
|||||||
"""
|
"""
|
||||||
在聊天历史的指定深度插入条目 (Pos 6)
|
在聊天历史的指定深度插入条目 (Pos 6)
|
||||||
返回一个包含 role 和 content 的字典列表,方便后续转换
|
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||||
|
|
||||||
|
✅ 过滤已被总结的消息(is_summarized=True 且 mes="")
|
||||||
"""
|
"""
|
||||||
# 先将历史转换为中间格式
|
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
|
||||||
msg_list = []
|
msg_list = []
|
||||||
for msg in history:
|
for msg in history:
|
||||||
|
# ✅ 跳过已被总结的空消息
|
||||||
|
if msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
|
||||||
|
continue
|
||||||
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
||||||
|
|
||||||
# 按 depth 分组插入
|
# 按 depth 分组插入
|
||||||
|
|||||||
361
backend/services/regex_service.py
Normal file
361
backend/services/regex_service.py
Normal 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()
|
||||||
189
backend/services/script_manager.py
Normal file
189
backend/services/script_manager.py
Normal 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✅ 脚本管理测试完成!")
|
||||||
95
backend/services/system_settings_service.py
Normal file
95
backend/services/system_settings_service.py
Normal 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()
|
||||||
161
backend/services/task_queue_manager.py
Normal file
161
backend/services/task_queue_manager.py
Normal 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()
|
||||||
427
backend/services/token_usage_service.py
Normal file
427
backend/services/token_usage_service.py
Normal 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()
|
||||||
@@ -4,9 +4,12 @@ LLM 客户端工具
|
|||||||
提供统一的 LLM 接口,支持多种模型提供商。
|
提供统一的 LLM 接口,支持多种模型提供商。
|
||||||
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
|
使用 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.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
|
from core.config import settings
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
def get_llm(
|
def get_llm(
|
||||||
@@ -86,3 +89,249 @@ def get_creative_llm(provider: str = "openai") -> BaseChatModel:
|
|||||||
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
|
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
|
||||||
"""获取支持流式输出的 LLM"""
|
"""获取支持流式输出的 LLM"""
|
||||||
return get_llm(provider, streaming=True)
|
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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 后缀,确认无误后可删除")
|
|
||||||
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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": "我很好,谢谢关心!你呢?"}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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": "好的,请提供数据,我会进行专业分析。"}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
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
1
data/encryption_key.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"repetition_penalty": 1,
|
"repetition_penalty": 1,
|
||||||
"openai_max_context": 4095,
|
"openai_max_context": 4095,
|
||||||
"openai_max_tokens": 300,
|
"openai_max_tokens": 300,
|
||||||
|
"request_timeout": 60,
|
||||||
"names_behavior": 0,
|
"names_behavior": 0,
|
||||||
"send_if_empty": "",
|
"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}}.]",
|
"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}}.",
|
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
||||||
"identifier": "main"
|
"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",
|
"identifier": "worldInfoBefore",
|
||||||
"name": "World Info (before)",
|
"name": "World Info (before)",
|
||||||
"system_prompt": true,
|
"system_prompt": true,
|
||||||
"marker": 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",
|
"identifier": "charDescription",
|
||||||
"name": "Char Description",
|
"name": "Char Description",
|
||||||
@@ -95,6 +56,46 @@
|
|||||||
"system_prompt": true,
|
"system_prompt": true,
|
||||||
"marker": 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",
|
"identifier": "personaDescription",
|
||||||
"name": "Persona Description",
|
"name": "Persona Description",
|
||||||
@@ -214,5 +215,8 @@
|
|||||||
"continue_prefill": false,
|
"continue_prefill": false,
|
||||||
"continue_postfix": " ",
|
"continue_postfix": " ",
|
||||||
"seed": -1,
|
"seed": -1,
|
||||||
"n": 1
|
"n": 1,
|
||||||
|
"updatedAt": 1777857993,
|
||||||
|
"name": "Default",
|
||||||
|
"createdAt": 1777977985
|
||||||
}
|
}
|
||||||
28
data/regex/global/default.json
Normal file
28
data/regex/global/default.json
Normal 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
|
||||||
|
}
|
||||||
35
data/regex/global/rule-hide-thinking-001.json
Normal file
35
data/regex/global/rule-hide-thinking-001.json
Normal 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
|
||||||
|
}
|
||||||
10
data/regex/global/ruleset-global-default.json
Normal file
10
data/regex/global/ruleset-global-default.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "ruleset-global-default",
|
||||||
|
"name": "默认全局规则集",
|
||||||
|
"description": "系统默认的全局正则规则",
|
||||||
|
"rules": [],
|
||||||
|
"createdAt": 1777998120,
|
||||||
|
"updatedAt": 1777998120,
|
||||||
|
"version": 1,
|
||||||
|
"isSillyTavernFormat": false
|
||||||
|
}
|
||||||
323
data/regex/presets/MyPreset.json
Normal file
323
data/regex/presets/MyPreset.json
Normal file
File diff suppressed because one or more lines are too long
35
data/regex/presets/test.json
Normal file
35
data/regex/presets/test.json
Normal 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
|
||||||
|
}
|
||||||
7
data/system_settings.json
Normal file
7
data/system_settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"thinkingTagPrefix": "<thinking>",
|
||||||
|
"thinkingTagSuffix": "</thinking>",
|
||||||
|
"currentPresetName": null,
|
||||||
|
"updatedAt": 1777798988,
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
14
data/token_usage/2026/05.jsonl
Normal file
14
data/token_usage/2026/05.jsonl
Normal 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"}
|
||||||
10
data/token_usage/indexes/api_urls.json
Normal file
10
data/token_usage/indexes/api_urls.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"https://api.deepseek.com/v1": {
|
||||||
|
"totalPromptTokens": 13890,
|
||||||
|
"totalCompletionTokens": 4435,
|
||||||
|
"totalTokens": 18325,
|
||||||
|
"count": 14,
|
||||||
|
"firstUsed": 1777984056,
|
||||||
|
"lastUsed": 1778167133
|
||||||
|
}
|
||||||
|
}
|
||||||
20
data/token_usage/indexes/daily/2026-05.json
Normal file
20
data/token_usage/indexes/daily/2026-05.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,6 @@ services:
|
|||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- 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"
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
218
frontend/Z_INDEX_GUIDE.md
Normal file
218
frontend/Z_INDEX_GUIDE.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Z-Index 层级规范文档
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
本文档定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致、可维护。
|
||||||
|
|
||||||
|
## 🎯 设计原则
|
||||||
|
|
||||||
|
1. **分层管理**:将 z-index 划分为 5 个主要层级,每层预留充足空间
|
||||||
|
2. **语义化命名**:使用有意义的变量名,而非魔法数字
|
||||||
|
3. **统一来源**:所有 z-index 值统一定义在 `z-index.css` 中
|
||||||
|
4. **易于扩展**:每层之间至少预留 100 的空间,方便插入新层级
|
||||||
|
|
||||||
|
## 📊 层级划分
|
||||||
|
|
||||||
|
### 1️⃣ 基础层 (0-99)
|
||||||
|
用于页面背景、基础布局等底层元素
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| `--z-background` | 0 | 最底层 - 背景装饰 |
|
||||||
|
| `--z-base-content` | 1 | 基础内容层 - 普通文本、图片 |
|
||||||
|
| `--z-divider` | 10 | 分割线、边框装饰 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 页面背景渐变
|
||||||
|
- 基础卡片容器
|
||||||
|
- 列表项默认状态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ 组件层 (100-999)
|
||||||
|
用于常规 UI 组件,如下拉菜单、悬浮提示等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| `--z-top-bar` | 100 | TopBar 导航栏 |
|
||||||
|
| `--z-sidebar` | 100 | 侧边栏容器 |
|
||||||
|
| `--z-dropdown-menu` | 1000 | 下拉菜单(预设操作、世界书选择) |
|
||||||
|
| `--z-sort-panel` | 1100 | 排序设置面板 |
|
||||||
|
| `--z-tooltip` | 1200 | 悬浮提示 Tooltip |
|
||||||
|
| `--z-chat-actions` | 1000 | 聊天消息操作按钮 |
|
||||||
|
| `--z-character-preview` | 1000 | 角色卡预览弹窗 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 点击按钮弹出的下拉菜单
|
||||||
|
- 鼠标悬停显示的提示信息
|
||||||
|
- 聊天消息的快捷操作按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ 弹窗层 (10000-19999)
|
||||||
|
用于模态对话框、编辑面板等需要覆盖整个页面的元素
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-modal-overlay` | 10000 | 对话框遮罩层背景 |
|
||||||
|
| `--z-modal-content` | 10100 | 对话框内容(API配置、预设保存等) |
|
||||||
|
| `--z-edit-panel-overlay` | 10200 | 世界书编辑面板遮罩层 |
|
||||||
|
| `--z-edit-panel-content` | 10300 | 世界书编辑面板内容 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- API 配置对话框
|
||||||
|
- 预设保存/编辑对话框
|
||||||
|
- 世界书条目编辑面板
|
||||||
|
- 任何需要全屏遮罩的模态窗口
|
||||||
|
|
||||||
|
**层级关系:**
|
||||||
|
```
|
||||||
|
编辑面板内容 (10300)
|
||||||
|
↓
|
||||||
|
编辑面板遮罩 (10200)
|
||||||
|
↓
|
||||||
|
对话框内容 (10100)
|
||||||
|
↓
|
||||||
|
对话框遮罩 (10000)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ 通知层 (20000-29999)
|
||||||
|
用于全局通知、Toast 提示等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-toast-container` | 20000 | Toast 通知容器 |
|
||||||
|
| `--z-toast-item` | 20100 | Toast 通知项 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 操作成功/失败的提示
|
||||||
|
- 系统通知
|
||||||
|
- 警告信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ 系统层 (30000+)
|
||||||
|
用于系统级元素,如加载动画、错误边界等
|
||||||
|
|
||||||
|
| 变量名 | 值 | 用途 |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `--z-loading-spinner` | 30000 | 全局加载动画 |
|
||||||
|
| `--z-error-boundary` | 30100 | 错误边界覆盖层 |
|
||||||
|
|
||||||
|
**使用场景:**
|
||||||
|
- 页面加载时的旋转动画
|
||||||
|
- 错误捕获后的全屏提示
|
||||||
|
- 系统级遮罩
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 使用指南
|
||||||
|
|
||||||
|
### CSS 中使用
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ✅ 推荐:使用 CSS 变量 */
|
||||||
|
.dropdown-menu {
|
||||||
|
z-index: var(--z-dropdown-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
z-index: var(--z-modal-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel {
|
||||||
|
z-index: var(--z-edit-panel-content);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript/JavaScript 中使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 推荐:导入常量
|
||||||
|
import { Z_INDEX } from '../styles/z-index';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
zIndex: Z_INDEX.DROPDOWN_MENU,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 避免的做法
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* ❌ 不要使用魔法数字 */
|
||||||
|
.dropdown-menu {
|
||||||
|
z-index: 1000; /* 难以理解,不易维护 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ❌ 不要使用过大的数值 */
|
||||||
|
.modal {
|
||||||
|
z-index: 99999; /* 不合理,可能导致层级混乱 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 添加新层级
|
||||||
|
|
||||||
|
如果需要添加新的 z-index 层级,请遵循以下步骤:
|
||||||
|
|
||||||
|
1. **确定所属层级**:根据元素类型选择合适的层级范围
|
||||||
|
2. **选择合适数值**:在该层级范围内选择一个未使用的值(预留 100 间隔)
|
||||||
|
3. **更新定义文件**:
|
||||||
|
- 在 `z-index.css` 中添加 CSS 变量
|
||||||
|
- 在 `z-index.ts` 中添加 TypeScript 常量
|
||||||
|
4. **更新文档**:在本文档中添加说明
|
||||||
|
|
||||||
|
**示例:添加一个新的工具提示层级**
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* z-index.css */
|
||||||
|
:root {
|
||||||
|
--z-help-tooltip: 1300; /* 在 tooltip (1200) 之上 */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// z-index.ts
|
||||||
|
export const Z_INDEX = {
|
||||||
|
// ...
|
||||||
|
HELP_TOOLTIP: 1300,
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 常见问题
|
||||||
|
|
||||||
|
### Q: 为什么弹窗层从 10000 开始?
|
||||||
|
A: 为了与组件层(100-999)保持足够的距离,避免未来在组件层添加更多层级时产生冲突。
|
||||||
|
|
||||||
|
### Q: 如果两个元素都需要弹窗层怎么办?
|
||||||
|
A: 使用不同的子层级,例如:
|
||||||
|
- 第一个弹窗:`--z-modal-content` (10100)
|
||||||
|
- 第二个弹窗:`--z-modal-content + 10` (10110)
|
||||||
|
|
||||||
|
### Q: 可以在 inline style 中使用吗?
|
||||||
|
A: 可以,但推荐使用 CSS 类。如果必须使用 inline style:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div style={{ zIndex: 'var(--z-dropdown-menu)' }}>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 相关文件
|
||||||
|
|
||||||
|
- **CSS 变量定义**:`frontend/src/styles/z-index.css`
|
||||||
|
- **TypeScript 常量**:`frontend/src/styles/z-index.ts`
|
||||||
|
- **全局样式引入**:`frontend/src/index.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 更新历史
|
||||||
|
|
||||||
|
| 日期 | 版本 | 更新内容 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 2026-05-04 | 1.0 | 初始版本,建立完整的 z-index 层级体系 |
|
||||||
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
@@ -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
|
|
||||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
@@ -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": []
|
|
||||||
}
|
|
||||||
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
@@ -1,6 +0,0 @@
|
|||||||
import {
|
|
||||||
require_react
|
|
||||||
} from "./chunk-CANBAPAS.js";
|
|
||||||
import "./chunk-5WRI5ZAA.js";
|
|
||||||
export default require_react();
|
|
||||||
//# sourceMappingURL=react.js.map
|
|
||||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
File diff suppressed because one or more lines are too long
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -1,44 +1,33 @@
|
|||||||
// frontend-react/src/App.jsx
|
// 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 TopBar from './components/TopBar';
|
||||||
import { ChatBox } from './components/Mid';
|
import { ChatBox } from './components/Mid';
|
||||||
import SideBarLeft from './components/SideBarLeft';
|
import SideBarLeft from './components/SideBarLeft';
|
||||||
import SideBarRight from './components/SideBarRight';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// 布局模式:'chat'(聊天模式) | 'edit'(编辑模式)
|
// ✅ 从 AppLayoutStore 获取状态和方法
|
||||||
const [layoutMode, setLayoutMode] = useState('chat');
|
const {
|
||||||
|
layoutMode,
|
||||||
// 左侧栏模式:'fixed'(固定)| 'smart'(智能)| 'expanded'(扩展)
|
sidebarMode,
|
||||||
const [sidebarMode, setSidebarMode] = useState(() => {
|
isSidebarHovered,
|
||||||
return localStorage.getItem('sidebarMode') || 'smart';
|
colorTheme,
|
||||||
});
|
setLayoutMode,
|
||||||
|
setSidebarMode,
|
||||||
// 智能模式的悬停状态
|
setSidebarHovered,
|
||||||
const [isSidebarHovered, setIsSidebarHovered] = useState(false);
|
setColorTheme
|
||||||
|
} = useAppLayoutStore();
|
||||||
|
|
||||||
// 防抖定时器引用
|
// 防抖定时器引用
|
||||||
const hoverTimeoutRef = useRef(null);
|
const hoverTimeoutRef = useRef(null);
|
||||||
const leaveTimeoutRef = 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 优化
|
// 处理鼠标进入左侧栏 - 使用 useCallback 优化
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
if (sidebarMode === 'smart') {
|
if (sidebarMode === 'smart') {
|
||||||
@@ -49,11 +38,11 @@ function App() {
|
|||||||
|
|
||||||
// 设置防抖延迟后展开
|
// 设置防抖延迟后展开
|
||||||
hoverTimeoutRef.current = setTimeout(() => {
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
setIsSidebarHovered(true);
|
setSidebarHovered(true); // ✅ 使用 store 方法
|
||||||
setLayoutMode('edit');
|
setLayoutMode('edit');
|
||||||
}, 400); // 400ms 防抖
|
}, 400); // 400ms 防抖
|
||||||
}
|
}
|
||||||
}, [sidebarMode]);
|
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
|
||||||
|
|
||||||
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
|
// 处理鼠标离开左侧栏 - 使用 useCallback 优化
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
@@ -65,11 +54,11 @@ function App() {
|
|||||||
|
|
||||||
// 设置延迟收起,给用户反应时间
|
// 设置延迟收起,给用户反应时间
|
||||||
leaveTimeoutRef.current = setTimeout(() => {
|
leaveTimeoutRef.current = setTimeout(() => {
|
||||||
setIsSidebarHovered(false);
|
setSidebarHovered(false); // ✅ 使用 store 方法
|
||||||
setLayoutMode('chat');
|
setLayoutMode('chat');
|
||||||
}, 250); // 250ms 延迟
|
}, 250); // 250ms 延迟
|
||||||
}
|
}
|
||||||
}, [sidebarMode]);
|
}, [sidebarMode, setSidebarHovered, setLayoutMode]);
|
||||||
|
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,34 +68,129 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 更新左侧栏模式(从设置面板调用)
|
// ✅ 初始化时应用主题到 DOM(确保页面加载时就显示正确的主题)
|
||||||
const updateSidebarMode = useCallback((mode) => {
|
useEffect(() => {
|
||||||
setSidebarMode(mode);
|
document.documentElement.setAttribute('data-theme', colorTheme);
|
||||||
// 切换模式时重置悬停状态
|
document.documentElement.setAttribute('data-color-theme', colorTheme);
|
||||||
setIsSidebarHovered(false);
|
}, [colorTheme]);
|
||||||
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
|
|
||||||
if (leaveTimeoutRef.current) clearTimeout(leaveTimeoutRef.current);
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
setLayoutMode('chat');
|
// console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
|
||||||
}
|
}
|
||||||
}, []);
|
} else {
|
||||||
|
// 从缓存恢复
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// console.error('[App] ❌ API 配置加载失败:', err);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
|
||||||
// 更新配色主题(从设置面板调用)
|
// 2. 加载预设列表
|
||||||
const updateColorTheme = useCallback((theme) => {
|
(async () => {
|
||||||
setColorTheme(theme);
|
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 (
|
return (
|
||||||
<div className={`app ${layoutMode}-mode`}>
|
<div className={`app ${layoutMode}-mode`}>
|
||||||
<TopBar
|
{/* ✅ TopBar 不再需要 props,直接从 Store 读取状态 */}
|
||||||
sidebarMode={sidebarMode}
|
<TopBar />
|
||||||
colorTheme={colorTheme}
|
|
||||||
onSidebarModeChange={updateSidebarMode}
|
|
||||||
onColorThemeChange={updateColorTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 主内容容器 */}
|
{/* 主内容容器 */}
|
||||||
<div className="main-container">
|
<div className="main-container">
|
||||||
|
|||||||
96
frontend/src/Store/AppLayoutSlice.jsx
Normal file
96
frontend/src/Store/AppLayoutSlice.jsx
Normal 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;
|
||||||
@@ -3,6 +3,8 @@ import { create } from 'zustand';
|
|||||||
import { subscribeWithSelector, persist } from 'zustand/middleware';
|
import { subscribeWithSelector, persist } from 'zustand/middleware';
|
||||||
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
|
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
|
||||||
import usePresetStore from '../SideBarLeft/PresetSlice';
|
import usePresetStore from '../SideBarLeft/PresetSlice';
|
||||||
|
import useCharacterStore from '../SideBarLeft/CharacterSlice'; // 引入角色卡 Store
|
||||||
|
import useWorldBookStore from '../SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||||
|
|
||||||
const useChatBoxStore = create(
|
const useChatBoxStore = create(
|
||||||
subscribeWithSelector(
|
subscribeWithSelector(
|
||||||
@@ -39,8 +41,8 @@ const useChatBoxStore = create(
|
|||||||
dynamicTable: false, // 动态表格
|
dynamicTable: false, // 动态表格
|
||||||
streamOutput: false, // 流式输出
|
streamOutput: false, // 流式输出
|
||||||
imageWorkflow: false, // 生图工作流
|
imageWorkflow: false, // 生图工作流
|
||||||
htmlRender: false, // HTML渲染
|
renderMode: 'markdown', // 渲染模式: 'none' | 'html' | 'markdown'
|
||||||
markdownRender: true, // Markdown渲染(默认开启)
|
autoDiceRoll: false, // 自动掷骰子替换(默认关闭)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置消息列表
|
// 设置消息列表
|
||||||
@@ -80,14 +82,35 @@ const useChatBoxStore = create(
|
|||||||
dynamicTable: false,
|
dynamicTable: false,
|
||||||
streamOutput: false,
|
streamOutput: false,
|
||||||
imageWorkflow: false,
|
imageWorkflow: false,
|
||||||
htmlRender: false,
|
renderMode: 'markdown',
|
||||||
markdownRender: true
|
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) => {
|
setChatBoxRoleAndChat: (role, chat) => {
|
||||||
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
// console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
|
||||||
set({
|
set({
|
||||||
currentRole: role,
|
currentRole: role,
|
||||||
currentChat: typeof chat === 'object' && chat !== null ? chat.chat_name : chat
|
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();
|
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 配置
|
// 获取 API 配置
|
||||||
const apiConfigStore = useApiConfigStore.getState();
|
const apiConfigStore = useApiConfigStore.getState();
|
||||||
|
|
||||||
// 获取预设配置
|
// 获取预设配置
|
||||||
const presetStore = usePresetStore.getState();
|
const presetStore = usePresetStore.getState();
|
||||||
|
|
||||||
|
// ✅ 获取角色卡数据
|
||||||
|
const characterStore = useCharacterStore.getState();
|
||||||
|
const selectedCharacter = characterStore.selectedCharacter;
|
||||||
|
|
||||||
|
// ✅ 获取世界书数据
|
||||||
|
const worldBookStore = useWorldBookStore.getState();
|
||||||
|
const globalWorldBooks = worldBookStore.globalWorldBooks;
|
||||||
|
|
||||||
// 关闭之前的WebSocket连接
|
// 关闭之前的WebSocket连接
|
||||||
if (wsConnection) {
|
if (wsConnection) {
|
||||||
wsConnection.close();
|
wsConnection.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算下一个楼层号
|
// ✅ 判断是重roll还是新消息
|
||||||
const nextFloor = get().getNextFloor(messages);
|
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({
|
set({
|
||||||
isGenerating: true,
|
isGenerating: true,
|
||||||
wsConnection: null, // 重置连接
|
wsConnection: null, // 重置连接
|
||||||
messages: [...messages, {
|
messages: [...messages, {
|
||||||
id: Date.now(),
|
id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID
|
||||||
floor: nextFloor,
|
floor: userFloor,
|
||||||
mes: content,
|
mes: processedContent, // 使用处理后的内容
|
||||||
is_user: true,
|
is_user: true,
|
||||||
name: userName || 'User',
|
name: userName || 'User',
|
||||||
sendDate: new Date().toISOString()
|
sendDate: new Date().toISOString()
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 统一使用WebSocket处理流式和非流式输出
|
// 统一使用WebSocket处理流式和非流式输出
|
||||||
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
|
// WebSocket 直接连接到后端,使用浏览器的 host
|
||||||
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/ws`;
|
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);
|
const ws = new WebSocket(wsUrl);
|
||||||
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
|
||||||
|
|
||||||
@@ -158,9 +264,25 @@ const useChatBoxStore = create(
|
|||||||
// 保存WebSocket连接到store
|
// 保存WebSocket连接到store
|
||||||
set({ wsConnection: ws });
|
set({ wsConnection: ws });
|
||||||
|
|
||||||
// 添加一个空的助手消息,稍后会更新
|
// ✅ 根据模式处理 AI 消息
|
||||||
const newMessageId = Date.now();
|
let newMessageId;
|
||||||
const assistantFloor = nextFloor + 1; // 助手消息的楼层是用户消息楼层+1
|
|
||||||
|
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) => ({
|
set((state) => ({
|
||||||
messages: [...state.messages, {
|
messages: [...state.messages, {
|
||||||
id: newMessageId,
|
id: newMessageId,
|
||||||
@@ -169,44 +291,163 @@ const useChatBoxStore = create(
|
|||||||
is_user: false,
|
is_user: false,
|
||||||
name: characterName || 'Assistant',
|
name: characterName || 'Assistant',
|
||||||
sendDate: new Date().toISOString()
|
sendDate: new Date().toISOString()
|
||||||
}]
|
}],
|
||||||
|
isGenerating: true
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let assistantMessage = '';
|
let assistantMessage = '';
|
||||||
let isStreamComplete = false;
|
let isStreamComplete = false;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
clearTimeout(connectionTimeout); // 清除超时定时器
|
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) => {
|
ws.onclose = (event) => {
|
||||||
console.log('[WebSocket] 连接已关闭', {
|
console.log('\n' + '='.repeat(80));
|
||||||
code: event.code,
|
console.log('[WebSocket] 🔌 连接已关闭');
|
||||||
reason: event.reason,
|
console.log(' - Code:', event.code);
|
||||||
wasClean: event.wasClean
|
console.log(' - Reason:', event.reason);
|
||||||
});
|
console.log(' - Was Clean:', event.wasClean);
|
||||||
|
console.log('='.repeat(80) + '\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理WebSocket消息
|
// 处理WebSocket消息
|
||||||
|
let chunkCount = 0;
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
console.log('[WebSocket] 收到消息', { type: data.type, content: data.content });
|
|
||||||
|
|
||||||
if (data.type === 'chunk') {
|
if (data.type === 'chunk') {
|
||||||
|
chunkCount++;
|
||||||
|
// 每10个chunk记录一次
|
||||||
|
if (chunkCount % 10 === 0) {
|
||||||
|
console.log(`[WebSocket] 📊 已接收 ${chunkCount} 个 chunks`);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理流式数据块
|
// 处理流式数据块
|
||||||
assistantMessage += data.content;
|
assistantMessage += data.content;
|
||||||
|
|
||||||
|
if (isReroll) {
|
||||||
|
// ✅ 重roll模式:不更新 mes,只在内部累积 assistantMessage
|
||||||
|
// mes 保持不变,直到 complete 事件才更新
|
||||||
|
} else {
|
||||||
|
// 正常模式:更新新创建的消息
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.map((msg) =>
|
messages: state.messages.map((msg) =>
|
||||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : 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') {
|
} 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;
|
isStreamComplete = true;
|
||||||
ws.close();
|
ws.close();
|
||||||
set({ wsConnection: null, isGenerating: false });
|
set({ wsConnection: null, isGenerating: false });
|
||||||
} else if (data.type === 'error') {
|
} else if (data.type === 'error') {
|
||||||
|
console.error('[WebSocket] ❌ 收到错误:', data.message);
|
||||||
// 错误处理
|
// 错误处理
|
||||||
set({
|
set({
|
||||||
error: data.message,
|
error: data.message,
|
||||||
@@ -236,36 +477,97 @@ const useChatBoxStore = create(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 发送请求到WebSocket(确保连接已建立)
|
|
||||||
// 发送请求到WebSocket(确保连接已建立)
|
// 发送请求到WebSocket(确保连接已建立)
|
||||||
const sendAfterConnect = () => {
|
const sendAfterConnect = () => {
|
||||||
|
console.log('[WebSocket] 📤 sendAfterConnect 被调用, readyState:', ws.readyState);
|
||||||
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
console.log('[WebSocket] 发送消息', { readyState: ws.readyState });
|
console.log('[WebSocket] ✅ 连接已打开,准备发送消息');
|
||||||
ws.send(JSON.stringify({
|
// ✅ 获取 API 配置
|
||||||
floor: nextFloor,
|
const apiConfigData = {
|
||||||
mes: content,
|
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,
|
is_user: true,
|
||||||
currentRole: currentRole,
|
currentRole: currentRole,
|
||||||
currentChat: currentChat,
|
currentChat: actualChat, // 使用 actualChat
|
||||||
options: options,
|
options: options,
|
||||||
apiConfig: {
|
apiConfig: {
|
||||||
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
|
// 从 currentProfile 中获取 mainLLM 配置(不包含 apiKey)
|
||||||
api_key: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_key || ''
|
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
|
||||||
|
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
|
||||||
|
},
|
||||||
|
// ✅ 传递 profileId,让后端从配置文件读取 API Key
|
||||||
|
currentProfile: {
|
||||||
|
id: apiConfigStore.currentProfile?.id || null
|
||||||
},
|
},
|
||||||
presetConfig: {
|
presetConfig: {
|
||||||
selectedPreset: presetStore.selectedPreset,
|
selectedPreset: presetStore.selectedPreset,
|
||||||
parameters: presetStore.parameters,
|
parameters: presetStore.parameters,
|
||||||
promptComponents: presetStore.promptComponents
|
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) {
|
} else if (ws.readyState === WebSocket.CONNECTING) {
|
||||||
// 如果正在连接,继续等待
|
// 如果正在连接,继续等待
|
||||||
console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
// console.log('[WebSocket] 等待连接...', { readyState: ws.readyState });
|
||||||
setTimeout(sendAfterConnect, 100);
|
setTimeout(sendAfterConnect, 100);
|
||||||
} else {
|
} else {
|
||||||
// 连接失败或已关闭
|
// 连接失败或已关闭
|
||||||
console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
|
// console.error('[WebSocket] 连接失败', { readyState: ws.readyState });
|
||||||
set({
|
set({
|
||||||
error: 'WebSocket connection failed',
|
error: 'WebSocket connection failed',
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
@@ -285,9 +587,24 @@ const useChatBoxStore = create(
|
|||||||
|
|
||||||
// 终止生成
|
// 终止生成
|
||||||
stopGeneration: () => set((state) => {
|
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) {
|
if (state.wsConnection) {
|
||||||
state.wsConnection.close();
|
state.wsConnection.close();
|
||||||
|
// console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
wsConnection: null
|
wsConnection: null
|
||||||
@@ -296,6 +613,14 @@ const useChatBoxStore = create(
|
|||||||
|
|
||||||
// 加载聊天历史
|
// 加载聊天历史
|
||||||
fetchChatHistory: async (roleName, chatName) => {
|
fetchChatHistory: async (roleName, chatName) => {
|
||||||
|
const currentState = get();
|
||||||
|
|
||||||
|
// 如果已经在加载中,跳过
|
||||||
|
if (currentState.isLoading) {
|
||||||
|
// console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
// 确俚chatName是字符串
|
// 确俚chatName是字符串
|
||||||
@@ -317,31 +642,36 @@ const useChatBoxStore = create(
|
|||||||
if (characterData.first_mes && characterData.first_mes.trim()) {
|
if (characterData.first_mes && characterData.first_mes.trim()) {
|
||||||
// 创建临时的开场白消息(不保存到后端)
|
// 创建临时的开场白消息(不保存到后端)
|
||||||
messages = [{
|
messages = [{
|
||||||
|
id: Date.now(), // ✅ 添加唯一 ID
|
||||||
floor: 1,
|
floor: 1,
|
||||||
mes: characterData.first_mes,
|
mes: characterData.first_mes,
|
||||||
is_user: false,
|
is_user: false,
|
||||||
name: characterData.name || roleName,
|
name: characterData.name || roleName,
|
||||||
sendDate: new Date().toISOString()
|
sendDate: new Date().toISOString()
|
||||||
}];
|
}];
|
||||||
console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
|
// console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ChatBoxStore] 获取角色信息失败:', error);
|
// console.warn('[ChatBoxStore] 获取角色信息失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只更新消息相关状态,不更新 currentRole/currentChat(避免触发监听器)
|
||||||
set({
|
set({
|
||||||
messages: messages,
|
messages: messages,
|
||||||
userName: data.metadata?.user_name || 'User',
|
userName: data.metadata?.user_name || 'User',
|
||||||
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
characterName: data.metadata?.character_name || roleName || 'Assistant',
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error.message,
|
error: error.message,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
// console.error('[ChatBoxStore] 加载聊天失败:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -473,7 +803,37 @@ const useChatBoxStore = create(
|
|||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
// 只持久化选项状态,不持久化聊天历史等
|
// 只持久化选项状态,不持久化聊天历史等
|
||||||
options: state.options
|
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(
|
useChatBoxStore.subscribe(
|
||||||
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
(state) => ({ role: state.currentRole, chat: state.currentChat }),
|
||||||
({ role, chat }, prev) => {
|
({ role, chat }, prev) => {
|
||||||
|
console.log(`[ChatBoxStore 监听器] 状态变化检测:`, {
|
||||||
|
当前: { role, chat },
|
||||||
|
之前: prev,
|
||||||
|
角色变化: role !== prev.role,
|
||||||
|
聊天变化: chat !== prev.chat
|
||||||
|
});
|
||||||
|
|
||||||
// 只有当角色或聊天发生变化时才处理
|
// 只有当角色或聊天发生变化时才处理
|
||||||
if (role !== prev.role || chat !== prev.chat) {
|
if (role !== prev.role || chat !== prev.chat) {
|
||||||
// 确保角色和聊天都存在且不为null
|
// 确保角色存在
|
||||||
if (role && chat) {
|
if (role) {
|
||||||
// 确保chat是字符串,如果是对象则提取chat_name
|
// 如果聊天也存在,加载聊天历史
|
||||||
|
if (chat) {
|
||||||
|
// 确俚chat是字符串,如果是对象则提取chat_name
|
||||||
const actualChat = typeof chat === 'object' && chat !== null ? chat.chat_name : chat;
|
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);
|
useChatBoxStore.getState().fetchChatHistory(role, actualChat);
|
||||||
} else {
|
} 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();
|
useChatBoxStore.getState().clearChatHistory();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[ChatBoxStore 监听器] ⏸️ 无变化,不触发加载`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
|
||||||
|
|||||||
154
frontend/src/Store/Mid/ChatBoxUISlice.jsx
Normal file
154
frontend/src/Store/Mid/ChatBoxUISlice.jsx
Normal 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;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
// Mid 区域相关的 Store
|
// Mid 区域相关的 Store
|
||||||
export { default as useChatBoxStore } from './ChatBoxSlice';
|
export { default as useChatBoxStore } from './ChatBoxSlice';
|
||||||
|
export { default as useChatBoxUIStore } from './ChatBoxUISlice';
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const useApiConfigStore = create(
|
|||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/api-config/profiles');
|
const response = await fetch('/api/api-config/profiles');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch profiles');
|
throw new Error('Failed to fetch profiles');
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,11 @@ const useApiConfigStore = create(
|
|||||||
throw new Error('Failed to fetch profile');
|
throw new Error('Failed to fetch profile');
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ currentProfile: data, loading: false });
|
set({
|
||||||
|
currentProfile: data,
|
||||||
|
currentProfileId: profileId, // ✅ 保存当前配置 ID
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: err.message, loading: false });
|
set({ error: err.message, loading: false });
|
||||||
@@ -193,8 +198,27 @@ const useApiConfigStore = create(
|
|||||||
{
|
{
|
||||||
name: 'ApiConfigStore',
|
name: 'ApiConfigStore',
|
||||||
partialize: (state) => ({
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
151
frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx
Normal file
151
frontend/src/Store/SideBarLeft/CharacterCardUISlice.jsx
Normal 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;
|
||||||
@@ -1,6 +1,113 @@
|
|||||||
import { create } from 'zustand';
|
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: '',
|
selectedPreset: '',
|
||||||
|
|
||||||
@@ -28,6 +135,10 @@ const usePresetStore = create((set, get) => ({
|
|||||||
// 参数设置折叠状态
|
// 参数设置折叠状态
|
||||||
isParametersExpanded: true,
|
isParametersExpanded: true,
|
||||||
|
|
||||||
|
// 分页状态
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 8,
|
||||||
|
|
||||||
// 预设组件列表
|
// 预设组件列表
|
||||||
promptComponents: [
|
promptComponents: [
|
||||||
{
|
{
|
||||||
@@ -114,7 +225,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
|
|
||||||
set({ presets: presetList, isLoadingPresets: false });
|
set({ presets: presetList, isLoadingPresets: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch presets:', error);
|
// console.error('Failed to fetch presets:', error);
|
||||||
set({ isLoadingPresets: false });
|
set({ isLoadingPresets: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,32 +237,52 @@ const usePresetStore = create((set, get) => ({
|
|||||||
const response = await fetch(`/api/presets/${presetId}`);
|
const response = await fetch(`/api/presets/${presetId}`);
|
||||||
const presetData = await response.json();
|
const presetData = await response.json();
|
||||||
|
|
||||||
// 记录原始数据用于调试
|
// 提取参数并更新状态,支持内部结构和SillyTavern结构
|
||||||
console.log('从后端获取的预设数据:', presetData);
|
|
||||||
|
|
||||||
// 提取参数并更新状态,确保所有参数都有默认值
|
|
||||||
const parameters = {
|
const parameters = {
|
||||||
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
temperature: presetData.temperature !== undefined ? presetData.temperature : 1.0,
|
||||||
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty : 0.0,
|
frequency_penalty: presetData.frequency_penalty !== undefined ? presetData.frequency_penalty :
|
||||||
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty : 0.0,
|
(presetData.frequencyPenalty !== undefined ? presetData.frequencyPenalty : 0.0),
|
||||||
top_p: presetData.top_p !== undefined ? presetData.top_p : 1.0,
|
presence_penalty: presetData.presence_penalty !== undefined ? presetData.presence_penalty :
|
||||||
top_k: presetData.top_k !== undefined ? presetData.top_k : 0,
|
(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 :
|
max_context: presetData.openai_max_context !== undefined ? presetData.openai_max_context :
|
||||||
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
(presetData.max_context !== undefined ? presetData.max_context : 1000000),
|
||||||
max_tokens: presetData.openai_max_tokens !== undefined ? presetData.openai_max_tokens :
|
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,
|
max_context_unlocked: presetData.max_context_unlocked !== undefined ? presetData.max_context_unlocked : false,
|
||||||
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
stream_openai: presetData.stream_openai !== undefined ? presetData.stream_openai : true,
|
||||||
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
seed: presetData.seed !== undefined ? presetData.seed : -1,
|
||||||
n: presetData.n !== undefined ? presetData.n : 1
|
n: presetData.n !== undefined ? presetData.n : 1
|
||||||
};
|
};
|
||||||
|
|
||||||
// 记录映射后的参数用于调试
|
// 处理预设组件 - 支持内部结构和SillyTavern结构
|
||||||
console.log('映射后的参数:', parameters);
|
|
||||||
|
|
||||||
// 处理预设组件
|
|
||||||
let components = [];
|
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,添加更严格的检查
|
// 获取当前角色的prompt_order,添加更严格的检查
|
||||||
const currentOrder = (presetData.prompt_order &&
|
const currentOrder = (presetData.prompt_order &&
|
||||||
Array.isArray(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({
|
set({
|
||||||
@@ -191,7 +340,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
isParametersExpanded: true // 确保参数容器展开
|
isParametersExpanded: true // 确保参数容器展开
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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]
|
presets: [...state.presets, preset]
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// 保存当前设置为预设
|
// 保存当前设置为预设 - 使用 SillyTavern 标准格式
|
||||||
saveCurrentAsPreset: async ({ name }) => {
|
saveCurrentAsPreset: async ({ name }) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
try {
|
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 = {
|
const presetData = {
|
||||||
...state.parameters,
|
// 基本参数 - 使用 SillyTavern 标准字段名
|
||||||
prompts: state.promptComponents.map(component => ({
|
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,
|
identifier: component.identifier,
|
||||||
name: component.name,
|
name: component.name,
|
||||||
content: component.content || '',
|
content: component.content || '',
|
||||||
role: component.role,
|
system_prompt: component.role === 0,
|
||||||
system_prompt: component.system_prompt,
|
role: component.role === 0 ? 'system' : component.role === 1 ? 'user' : 'assistant',
|
||||||
marker: component.marker
|
enabled: component.enabled !== false
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
// prompt_order - SillyTavern 用于管理顺序和启用状态
|
||||||
prompt_order: [{
|
prompt_order: [{
|
||||||
order: state.promptComponents.map(component => ({
|
character_id: 'global',
|
||||||
|
order: componentsToSave.map(component => ({
|
||||||
identifier: component.identifier,
|
identifier: component.identifier,
|
||||||
enabled: component.enabled !== false
|
enabled: component.enabled !== false
|
||||||
}))
|
}))
|
||||||
@@ -234,10 +417,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(presetData)
|
||||||
preset_name: name,
|
|
||||||
...presetData
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -246,9 +426,9 @@ const usePresetStore = create((set, get) => ({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// 添加到本地预设列表
|
// ✅ 添加到本地预设列表(新预设排在最前面)
|
||||||
const newPreset = {
|
const newPreset = {
|
||||||
id: name,
|
id: name, // 使用名称作为 ID
|
||||||
name,
|
name,
|
||||||
description: '',
|
description: '',
|
||||||
component_count: state.promptComponents.length,
|
component_count: state.promptComponents.length,
|
||||||
@@ -256,13 +436,13 @@ const usePresetStore = create((set, get) => ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
presets: [...state.presets, newPreset],
|
presets: [newPreset, ...state.presets], // ✅ 新预设插入到最前面
|
||||||
selectedPreset: name
|
selectedPreset: name
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save preset:', error);
|
// console.error('Failed to save preset:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -293,7 +473,7 @@ const usePresetStore = create((set, get) => ({
|
|||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update preset name:', error);
|
// console.error('Failed to update preset name:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -303,37 +483,97 @@ const usePresetStore = create((set, get) => ({
|
|||||||
isParametersExpanded: !state.isParametersExpanded
|
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];
|
const newComponents = [...state.promptComponents];
|
||||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||||
return { promptComponents: newComponents };
|
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];
|
const newComponents = [...state.promptComponents];
|
||||||
newComponents[index] = {
|
newComponents[index] = {
|
||||||
...newComponents[index],
|
...newComponents[index],
|
||||||
enabled: !newComponents[index].enabled
|
enabled: !newComponents[index].enabled
|
||||||
};
|
};
|
||||||
return { promptComponents: newComponents };
|
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]
|
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];
|
const newComponents = [...state.promptComponents];
|
||||||
newComponents.splice(index, 1);
|
newComponents.splice(index, 1);
|
||||||
return { promptComponents: newComponents };
|
return { promptComponents: newComponents };
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
// ✅ 自动保存到后端
|
||||||
|
const state = get();
|
||||||
|
if (state.selectedPreset) {
|
||||||
|
try {
|
||||||
|
await state._autoSavePreset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PresetStore] 自动保存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 移动组件位置
|
// 移动组件位置
|
||||||
moveComponent: (fromIndex, toIndex) => set((state) => {
|
moveComponent: (fromIndex, toIndex) => set((state) => {
|
||||||
@@ -343,6 +583,41 @@ const usePresetStore = create((set, get) => ({
|
|||||||
return { promptComponents: newComponents };
|
return { promptComponents: newComponents };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 保存组件排序到后端
|
||||||
|
saveComponentOrder: async () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.selectedPreset) {
|
||||||
|
// console.warn('No preset selected, cannot save order');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 提取组件 identifier 列表,按当前顺序
|
||||||
|
const componentOrder = state.promptComponents.map(component => component.identifier);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/presets/${state.selectedPreset}/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
component_order: componentOrder
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to save component order');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
// console.log('Component order saved successfully:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Failed to save component order:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 获取当前预设的prompt_order
|
// 获取当前预设的prompt_order
|
||||||
getPromptOrder: () => {
|
getPromptOrder: () => {
|
||||||
const { promptComponents } = get();
|
const { promptComponents } = get();
|
||||||
@@ -350,7 +625,63 @@ const usePresetStore = create((set, get) => ({
|
|||||||
identifier: component.identifier,
|
identifier: component.identifier,
|
||||||
enabled: component.enabled !== false
|
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;
|
export default usePresetStore;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const useSideBarLeftStore = create(
|
|||||||
{ id: 'character', label: '角色', title: '管理AI角色卡' },
|
{ id: 'character', label: '角色', title: '管理AI角色卡' },
|
||||||
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
|
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
|
||||||
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
|
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
|
||||||
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' }
|
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' },
|
||||||
|
{ id: 'tokenUsage', label: 'Token', title: '查看 Token 使用统计' }
|
||||||
],
|
],
|
||||||
|
|
||||||
setActiveTab: (tab) => set({ activeTab: tab })
|
setActiveTab: (tab) => set({ activeTab: tab })
|
||||||
|
|||||||
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal file
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal 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;
|
||||||
@@ -4,3 +4,4 @@ export { default as useApiConfigStore } from './ApiConfigSlice';
|
|||||||
export { default as usePresetStore } from './PresetSlice';
|
export { default as usePresetStore } from './PresetSlice';
|
||||||
export { default as useWorldBookStore } from './WorldBookSlice';
|
export { default as useWorldBookStore } from './WorldBookSlice';
|
||||||
export { default as useCharacterStore } from './CharacterSlice';
|
export { default as useCharacterStore } from './CharacterSlice';
|
||||||
|
export { default as useCharacterCardUIStore } from './CharacterCardUISlice';
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ import { persist } from 'zustand/middleware';
|
|||||||
|
|
||||||
const useSideBarRightStore = create(
|
const useSideBarRightStore = create(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
selectedTabs: ['dice', 'rag'],
|
selectedTabs: ['dice', 'rag'],
|
||||||
|
|
||||||
allTabs: [
|
allTabs: [
|
||||||
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
|
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
|
||||||
{ id: 'debug', label: '调试', title: '查看具体发送了哪些上下文', component: null },
|
|
||||||
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
|
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
|
||||||
{ id: 'table', 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) => {
|
handleTabClick: (tabId) => set((state) => {
|
||||||
@@ -32,11 +33,46 @@ const useSideBarRightStore = create(
|
|||||||
allTabs: state.allTabs.map(tab =>
|
allTabs: state.allTabs.map(tab =>
|
||||||
tab.id === tabId ? { ...tab, component } : 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
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
215
frontend/src/Store/SideBarRight/TableSlice.jsx
Normal file
215
frontend/src/Store/SideBarRight/TableSlice.jsx
Normal 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;
|
||||||
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal file
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal 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;
|
||||||
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal file
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal 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;
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
// SideBarRight 相关的 Store
|
// SideBarRight 相关的 Store
|
||||||
export { default as useSideBarRightStore } from './SideBarRightSlice';
|
export { default as useSideBarRightStore } from './SideBarRightSlice';
|
||||||
|
export { default as useTableStore } from './TableSlice';
|
||||||
|
|||||||
87
frontend/src/Store/UserSlice.jsx
Normal file
87
frontend/src/Store/UserSlice.jsx
Normal 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;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// frontend-react/src/store/index.js
|
// frontend-react/src/store/index.js
|
||||||
// 统一导出所有 Store,方便外部使用
|
// 统一导出所有 Store,方便外部使用
|
||||||
export { useRoleSelectorStore } from './TopBar';
|
export { useRoleSelectorStore } from './TopBar';
|
||||||
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore } from './SideBarLeft';
|
export { useSideBarLeftStore, useApiConfigStore, usePresetStore, useWorldBookStore, useCharacterCardUIStore } from './SideBarLeft';
|
||||||
export { useSideBarRightStore } from './SideBarRight';
|
export { useSideBarRightStore, useTableStore } from './SideBarRight';
|
||||||
export { useChatBoxStore } from './Mid';
|
export { useChatBoxStore, useChatBoxUIStore } from './Mid';
|
||||||
|
export { default as useAppLayoutStore } from './AppLayoutSlice';
|
||||||
|
export { default as useUserStore } from './UserSlice'; // ✅ 新增
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
padding-bottom: 180px; /* 为固定的输入框留出空间 */
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
border-radius: 0; /* 去掉圆角 */
|
border-radius: 0; /* 去掉圆角 */
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
position: relative;
|
position: relative; /* ✅ 为绝对定位的 swipe 按钮提供定位上下文 */
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -53,19 +52,29 @@
|
|||||||
/* 用户消息 - 右侧对齐,用色彩区分 */
|
/* 用户消息 - 右侧对齐,用色彩区分 */
|
||||||
.message.user {
|
.message.user {
|
||||||
align-self: stretch;
|
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);
|
color: var(--color-text-primary);
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AI 消息 - 左侧对齐,用色彩区分 */
|
/* AI 消息 - 左侧对齐,用色彩区分 */
|
||||||
|
/* 参考 Discord/Slack/Notion 的夜间模式配色 */
|
||||||
.message.ai {
|
.message.ai {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
background-color: var(--color-bg-elevated); /* 使用主题的浅色背景形成对比 */
|
/* 日间模式:半透明白色背景 */
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
border-left: 3px solid transparent;
|
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 {
|
.message-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -129,6 +138,7 @@
|
|||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 确保编辑时保持高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
@@ -162,22 +172,24 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 至少保持一行文本的高度 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-textarea {
|
.edit-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: auto; /* 自适应内容高度 */
|
min-height: calc(1lh + var(--spacing-xs) * 2); /* ✅ 与 bubble 的 padding 保持一致 */
|
||||||
max-height: none; /* 不限制最大高度 */
|
max-height: none; /* 不限制最大高度 */
|
||||||
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
padding: var(--spacing-xs) 0; /* 与 bubble 保持一致 */
|
||||||
border: none; /* 去掉边框 */
|
border: 2px solid var(--color-accent); /* ✅ 添加边框提示编辑状态 */
|
||||||
border-radius: 0;
|
border-radius: var(--radius-sm);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
background-color: transparent; /* 透明背景 */
|
background-color: rgba(102, 126, 234, 0.05); /* ✅ 轻微背景色区分编辑状态 */
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
box-sizing: border-box; /* ✅ 确保宽度计算正确 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-textarea:focus {
|
.edit-textarea:focus {
|
||||||
@@ -222,36 +234,87 @@
|
|||||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
opacity: 0; /* ✅ 默认隐藏 */
|
||||||
|
pointer-events: auto; /* ✅ 按钮本身可以接收点击 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-button {
|
/* 深色主题下的 swipe 按钮 */
|
||||||
background: none;
|
[data-color-theme='dark'] .swipe-button-side {
|
||||||
border: 1px solid #ddd;
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 4px;
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-button:hover:not(:disabled) {
|
/* ✅ hover 消息时显示按钮 */
|
||||||
background-color: rgba(0, 0, 0, 0.1);
|
.message:hover .swipe-button-side {
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-button:disabled {
|
.swipe-button-side:hover:not(:disabled) {
|
||||||
opacity: 0.5;
|
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;
|
cursor: not-allowed;
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-counter {
|
.swipe-counter-side {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
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 ==================== */
|
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||||
@@ -263,7 +326,7 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background-color: var(--color-bg-primary); /* 跟随主题 */
|
background-color: var(--color-bg-primary); /* 跟随主题 */
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
z-index: 10;
|
z-index: var(--z-divider); /* ✅ 基础层 - 分割线层级 */
|
||||||
border-top: 1px solid var(--color-border-light);
|
border-top: 1px solid var(--color-border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,27 +383,70 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + var(--spacing-sm));
|
bottom: calc(100% + var(--spacing-sm));
|
||||||
left: 0;
|
left: 0;
|
||||||
background: #ffffff;
|
background: var(--color-bg-elevated);
|
||||||
border: 1px solid rgba(102, 126, 234, 0.2);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-sm);
|
||||||
min-width: 200px;
|
min-width: 220px;
|
||||||
z-index: 100;
|
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 {
|
.option-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-md);
|
||||||
transition: background-color 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-checkbox:hover {
|
.option-checkbox:hover {
|
||||||
background-color: rgba(102, 126, 234, 0.05);
|
background-color: rgba(102, 126, 234, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-checkbox input[type="checkbox"] {
|
.option-checkbox input[type="checkbox"] {
|
||||||
@@ -348,15 +454,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkmark {
|
.checkmark {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
border: 2px solid rgba(102, 126, 234, 0.3);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-checkbox:hover .checkmark {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.option-checkbox input:checked + .checkmark {
|
.option-checkbox input:checked + .checkmark {
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
@@ -365,8 +475,8 @@
|
|||||||
.option-checkbox input:checked + .checkmark::after {
|
.option-checkbox input:checked + .checkmark::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 5px;
|
left: 4px;
|
||||||
top: 2px;
|
top: 1px;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border: solid white;
|
border: solid white;
|
||||||
@@ -378,17 +488,18 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 分隔线 */
|
||||||
.option-divider {
|
.option-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: linear-gradient(to right,
|
background-color: var(--color-border);
|
||||||
transparent,
|
|
||||||
rgba(102, 126, 234, 0.15),
|
|
||||||
transparent);
|
|
||||||
margin: var(--spacing-sm) 0;
|
margin: var(--spacing-sm) 0;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 切换聊天按钮 */
|
||||||
.switch-chat-btn {
|
.switch-chat-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
@@ -399,7 +510,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.15s ease;
|
||||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
|
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);
|
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-chat-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -426,13 +541,20 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
resize: none;
|
resize: none;
|
||||||
overflow-y: hidden;
|
overflow-y: auto;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
/* ✅ 业界标准:使用 field-sizing 实现自适应高度 */
|
||||||
|
/* 支持此属性的浏览器会自动根据内容调整高度 */
|
||||||
|
field-sizing: content;
|
||||||
|
|
||||||
|
/* ✅ 兼容性方案:为不支持 field-sizing 的浏览器提供回退 */
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input:focus {
|
.message-input:focus {
|
||||||
@@ -518,7 +640,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-selector-modal {
|
.chat-selector-modal {
|
||||||
@@ -574,6 +696,40 @@
|
|||||||
padding: var(--spacing-lg);
|
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 {
|
.chat-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -587,6 +743,10 @@
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
|
position: relative; /* ✅ 为删除按钮定位 */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-option:hover {
|
.chat-option:hover {
|
||||||
@@ -596,6 +756,31 @@
|
|||||||
|
|
||||||
.chat-option-content {
|
.chat-option-content {
|
||||||
cursor: pointer;
|
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 {
|
.chat-option-name {
|
||||||
@@ -626,3 +811,5 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 右键菜单样式已移至全局 context-menu.css */
|
||||||
|
|||||||
@@ -1,24 +1,50 @@
|
|||||||
// frontend-react/src/components/ChatBox/ChatBox.jsx
|
// 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 useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||||
|
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
|
||||||
|
import { processTextForDisplay } from '../../../hooks/useRegexProcessor'; // ✅ 修改:导入普通函数
|
||||||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||||||
import './ChatBox.css';
|
import './ChatBox.css';
|
||||||
|
|
||||||
const ChatBox = () => {
|
const ChatBox = () => {
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [editContent, setEditContent] = useState('');
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
|
||||||
const optionsRef = useRef(null);
|
const optionsRef = useRef(null);
|
||||||
|
|
||||||
// 聊天选择器相关状态
|
|
||||||
const [showChatSelector, setShowChatSelector] = useState(false);
|
|
||||||
const [characterChats, setCharacterChats] = useState([]);
|
|
||||||
const chatSelectorRef = useRef(null);
|
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 获取状态和方法
|
// 从 ChatBoxStore 获取状态和方法
|
||||||
const {
|
const {
|
||||||
@@ -32,10 +58,50 @@ const ChatBox = () => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
options,
|
options,
|
||||||
toggleOption
|
toggleOption,
|
||||||
|
cycleRenderMode // ✅ 新增:切换渲染模式
|
||||||
} = useChatBoxStore();
|
} = 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(() => {
|
useEffect(() => {
|
||||||
@@ -73,13 +139,34 @@ const ChatBox = () => {
|
|||||||
};
|
};
|
||||||
}, [showChatSelector]);
|
}, [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 handleInputHeight = (e) => {
|
||||||
const textarea = e.target;
|
const textarea = e.target;
|
||||||
|
|
||||||
// 标准方案:先重置为 auto,再设置为 scrollHeight
|
// ✅ 业界标准方案:先重置为 auto,再设置为 scrollHeight
|
||||||
// 这是业界公认的最佳实践,确保高度计算准确
|
// 这是 SillyTavern、Discord、Slack 等成熟产品使用的方案
|
||||||
|
// 确保删除内容时高度也能正确收缩
|
||||||
textarea.style.height = 'auto';
|
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();
|
stopGeneration();
|
||||||
} else {
|
} else {
|
||||||
sendMessage(inputValue);
|
sendMessage(inputValue);
|
||||||
setInputValue('');
|
clearInput(); // ✅ 使用 store 方法
|
||||||
setInputHeight(24); // 重置为一行高度
|
// ✅ 重置输入框高度:直接操作 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' });
|
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(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages]);
|
}, [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) => {
|
const handleEdit = (message) => {
|
||||||
setEditingId(message.floor);
|
startEditing(message.floor, message.mes); // ✅ 使用 store 方法
|
||||||
setEditContent(message.mes);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存编辑
|
// 保存编辑
|
||||||
const handleSaveEdit = (messageId) => {
|
const handleSaveEdit = (messageId) => {
|
||||||
// 调用 store 中的 updateMessage 方法
|
// 调用 store 中的 updateMessage 方法
|
||||||
updateMessage(messageId, editContent);
|
updateMessage(messageId, editContent);
|
||||||
setEditingId(null);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditContent('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 取消编辑
|
// 取消编辑
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setEditingId(null);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditContent('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 新增:处理swipe切换
|
// 新增:处理swipe切换
|
||||||
@@ -140,17 +318,176 @@ const ChatBox = () => {
|
|||||||
const newIndex = currentIndex + direction;
|
const newIndex = currentIndex + direction;
|
||||||
|
|
||||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||||
setCurrentSwipeId(prev => ({
|
// ✅ 合并更新,保留其他楼层的 swipe 状态
|
||||||
...prev,
|
setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex });
|
||||||
[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 = () => {
|
const toggleOptionsPanel = () => {
|
||||||
setShowOptions(!showOptions);
|
toggleOptions(); // ✅ 使用 store 方法
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开聊天选择器
|
// 打开聊天选择器
|
||||||
@@ -169,10 +506,10 @@ const ChatBox = () => {
|
|||||||
if (!response.ok) throw new Error('获取聊天列表失败');
|
if (!response.ok) throw new Error('获取聊天列表失败');
|
||||||
|
|
||||||
const chats = await response.json();
|
const chats = await response.json();
|
||||||
setCharacterChats(chats);
|
setCharacterChats(chats); // ✅ 使用 store 方法
|
||||||
|
|
||||||
// 显示聊天选择器弹窗,让用户手动选择
|
// 显示聊天选择器弹窗,让用户手动选择
|
||||||
setShowChatSelector(true);
|
toggleChatSelector(); // ✅ 使用 store 方法
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取聊天列表失败:', error);
|
console.error('获取聊天列表失败:', error);
|
||||||
alert('获取聊天列表失败: ' + error.message);
|
alert('获取聊天列表失败: ' + error.message);
|
||||||
@@ -182,16 +519,13 @@ const ChatBox = () => {
|
|||||||
// 选择聊天并加载
|
// 选择聊天并加载
|
||||||
const handleSelectChat = async (chatName) => {
|
const handleSelectChat = async (chatName) => {
|
||||||
try {
|
try {
|
||||||
const { setChatBoxRoleAndChat, fetchChatHistory, currentRole } = useChatBoxStore.getState();
|
const { setChatBoxRoleAndChat, currentRole } = useChatBoxStore.getState();
|
||||||
|
|
||||||
// 设置当前角色和聊天
|
// 设置当前角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
|
||||||
setChatBoxRoleAndChat(currentRole, chatName);
|
setChatBoxRoleAndChat(currentRole, chatName);
|
||||||
|
|
||||||
// 加载聊天历史
|
|
||||||
await fetchChatHistory(currentRole, chatName);
|
|
||||||
|
|
||||||
// 关闭选择器
|
// 关闭选择器
|
||||||
setShowChatSelector(false);
|
setShowChatSelector(false); // ✅ 使用 store 方法
|
||||||
|
|
||||||
console.log(`已切换到聊天: ${chatName}`);
|
console.log(`已切换到聊天: ${chatName}`);
|
||||||
} catch (error) {
|
} 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 isUser = message.is_user;
|
||||||
const isEditing = editingId === message.floor;
|
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 (
|
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-container">
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<span className="message-name">{displayName}</span>
|
<span className="message-name">{displayName}</span>
|
||||||
<span className="message-id">#{message.floor}</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>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
@@ -261,7 +690,7 @@ const ChatBox = () => {
|
|||||||
<textarea
|
<textarea
|
||||||
className="edit-textarea"
|
className="edit-textarea"
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => updateEditContent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="edit-buttons">
|
<div className="edit-buttons">
|
||||||
<button
|
<button
|
||||||
@@ -280,33 +709,25 @@ const ChatBox = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bubble">
|
<div className="bubble">
|
||||||
{options.htmlRender && !isUser ? (
|
{options.renderMode === 'html' && !isUser ? (
|
||||||
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
|
// ✅ HTML 渲染:只在检测到 HTML 标签时使用 dangerouslySetInnerHTML
|
||||||
) : options.markdownRender ? (
|
// ✅ 如果是纯文本,使用 plain-text 模式显示(保留原始格式)
|
||||||
<MarkdownRenderer content={currentMes} />
|
(() => {
|
||||||
|
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>
|
// ✅ 纯文本模式
|
||||||
)}
|
<div className="plain-text">{displayMes}</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -318,7 +739,7 @@ const ChatBox = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-box">
|
<div className="chat-box">
|
||||||
<div className="chat-messages">
|
<div className="chat-messages" ref={messagesContainerRef}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="loading">加载中...</div>
|
<div className="loading">加载中...</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -336,30 +757,25 @@ const ChatBox = () => {
|
|||||||
<button
|
<button
|
||||||
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
className={`options-toggle ${showOptions ? 'active' : ''}`}
|
||||||
title="Toggle Options"
|
title="Toggle Options"
|
||||||
onClick={() => setShowOptions(!showOptions)}
|
onClick={toggleOptions} // ✅ 使用 store 方法
|
||||||
>
|
>
|
||||||
{showOptions ? '×' : '≡'}
|
{showOptions ? '×' : '≡'}
|
||||||
</button>
|
</button>
|
||||||
{showOptions && (
|
{showOptions && (
|
||||||
<div className="chat-options">
|
<div className="chat-options">
|
||||||
<label className="option-checkbox">
|
{/* 渲染模式切换按钮 */}
|
||||||
<input
|
<div className="option-group-title">显示</div>
|
||||||
type="checkbox"
|
<button
|
||||||
checked={options.markdownRender}
|
className="render-mode-btn"
|
||||||
onChange={() => toggleOption('markdownRender')}
|
onClick={() => cycleRenderMode()}
|
||||||
/>
|
title={`当前: ${options.renderMode === 'none' ? '纯文本' : options.renderMode === 'html' ? 'HTML' : 'Markdown'},点击切换`}
|
||||||
<span className="checkmark"></span>
|
>
|
||||||
<span className="option-label">Markdown渲染</span>
|
{options.renderMode === 'none' ? '📄 纯文本' :
|
||||||
</label>
|
options.renderMode === 'html' ? '🌐 HTML' :
|
||||||
<label className="option-checkbox">
|
'📝 Markdown'}
|
||||||
<input
|
</button>
|
||||||
type="checkbox"
|
|
||||||
checked={options.htmlRender}
|
<div className="option-group-title">功能</div>
|
||||||
onChange={() => toggleOption('htmlRender')}
|
|
||||||
/>
|
|
||||||
<span className="checkmark"></span>
|
|
||||||
<span className="option-label">HTML渲染</span>
|
|
||||||
</label>
|
|
||||||
<label className="option-checkbox">
|
<label className="option-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -379,6 +795,17 @@ const ChatBox = () => {
|
|||||||
<span className="option-label">动态表格</span>
|
<span className="option-label">动态表格</span>
|
||||||
</label>
|
</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">
|
<label className="option-checkbox">
|
||||||
<input
|
<input
|
||||||
@@ -413,10 +840,6 @@ const ChatBox = () => {
|
|||||||
handleInputHeight(e);
|
handleInputHeight(e);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={{
|
|
||||||
height: `${inputHeight}px`,
|
|
||||||
overflowY: inputHeight >= 300 ? 'auto' : 'hidden'
|
|
||||||
}}
|
|
||||||
placeholder="Type your message..."
|
placeholder="Type your message..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,11 +873,21 @@ const ChatBox = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="chat-selector-body">
|
<div className="chat-selector-body">
|
||||||
|
{/* 新建聊天按钮 */}
|
||||||
|
<div className="chat-selector-actions">
|
||||||
|
<button
|
||||||
|
className="new-chat-btn"
|
||||||
|
onClick={handleCreateNewChat}
|
||||||
|
>
|
||||||
|
+ 新建聊天
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{characterChats.length > 0 ? (
|
{characterChats.length > 0 ? (
|
||||||
<div className="chat-list">
|
<div className="chat-list">
|
||||||
{characterChats.map((chat, index) => (
|
{characterChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={chat.chat_name}
|
||||||
className="chat-option"
|
className="chat-option"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -468,6 +901,14 @@ const ChatBox = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* ✅ 删除按钮 */}
|
||||||
|
<button
|
||||||
|
className="chat-delete-btn"
|
||||||
|
onClick={(e) => handleDeleteChat(chat.chat_name, e)}
|
||||||
|
title="删除此聊天"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,6 +921,50 @@ const ChatBox = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: visible; /* ✅ 允许 fixed 定位的子元素显示 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tabs {
|
.sidebar-tabs {
|
||||||
@@ -47,10 +47,29 @@
|
|||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--spacing-lg);
|
padding: 0; /* ✅ 移除padding,滚动条作为间隔 */
|
||||||
min-height: 0;
|
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 for empty states */
|
||||||
.tab-placeholder {
|
.tab-placeholder {
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
@@ -75,3 +94,29 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
// frontend-react/src/components/SideBarLeft/SideBarLeft.jsx
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import './SideBarLeft.css';
|
import './SideBarLeft.css';
|
||||||
import { useSideBarLeftStore } from '../../Store/indexStore';
|
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 Gallery from './tabs/Gallery';
|
||||||
import CharacterCard from './tabs/CharacterCard';
|
import CharacterCard from './tabs/CharacterCard';
|
||||||
import ApiConfig from './tabs/ApiConfig';
|
import ApiConfig from './tabs/ApiConfig';
|
||||||
import Presets from './tabs/Presets';
|
import Presets from './tabs/Presets';
|
||||||
import WorldBook from './tabs/WorldBook';
|
import WorldBook from './tabs/WorldBook';
|
||||||
|
import TokenUsage from './tabs/TokenUsage/TokenUsage';
|
||||||
|
|
||||||
const SideBarLeft = () => {
|
const SideBarLeft = () => {
|
||||||
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
|
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 (
|
return (
|
||||||
<div className="sidebar-left">
|
<div className="sidebar-left">
|
||||||
@@ -19,8 +69,9 @@ const SideBarLeft = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => handleTabClick(tab.id)}
|
||||||
title={tab.title}
|
onContextMenu={(e) => handleContextMenu(e, tab)} // ✅ 添加右键菜单
|
||||||
|
// ✅ 移除 title,改用右键菜单显示详情
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -33,7 +84,69 @@ const SideBarLeft = () => {
|
|||||||
{activeTab === 'api' && <ApiConfig />}
|
{activeTab === 'api' && <ApiConfig />}
|
||||||
{activeTab === 'presets' && <Presets />}
|
{activeTab === 'presets' && <Presets />}
|
||||||
{activeTab === 'worldbook' && <WorldBook />}
|
{activeTab === 'worldbook' && <WorldBook />}
|
||||||
|
{activeTab === 'tokenUsage' && <TokenUsage />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ApiConfig - Compact Modern Design */
|
/* ApiConfig - Compact Modern Design */
|
||||||
.api-config-container {
|
.api-config-container {
|
||||||
padding: var(--spacing-md);
|
padding: 0; /* sidebar-content已有padding,这里不需要 */
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -281,7 +281,7 @@ textarea.form-control {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
box-shadow: var(--shadow-xl);
|
box-shadow: var(--shadow-xl);
|
||||||
animation: slideIn 0.3s ease-out;
|
animation: slideIn 0.3s ease-out;
|
||||||
z-index: 1000;
|
z-index: var(--z-toast-item); /* ✅ 通知层 - Toast 项 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-success {
|
.notification-success {
|
||||||
@@ -326,7 +326,7 @@ textarea.form-control {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
animation: fadeIn 0.2s ease-out;
|
animation: fadeIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
|
|||||||
import './ApiConfig.css';
|
import './ApiConfig.css';
|
||||||
|
|
||||||
const ApiConfig = () => {
|
const ApiConfig = () => {
|
||||||
// 从store中获取状态和方法
|
// 从 store中获取状态和方法
|
||||||
const {
|
const {
|
||||||
profiles,
|
profiles,
|
||||||
currentProfile,
|
currentProfile,
|
||||||
@@ -77,22 +77,12 @@ const ApiConfig = () => {
|
|||||||
// 跟踪哪些 API 有修改
|
// 跟踪哪些 API 有修改
|
||||||
const [modifiedApis, setModifiedApis] = useState({});
|
const [modifiedApis, setModifiedApis] = useState({});
|
||||||
|
|
||||||
|
// 跟踪用户是否正在编辑 API Key(用于控制脱敏显示)
|
||||||
|
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
|
||||||
|
|
||||||
// 获取当前激活的分页
|
// 获取当前激活的分页
|
||||||
const { activeTab } = useSideBarLeftStore();
|
const { activeTab } = useSideBarLeftStore();
|
||||||
|
|
||||||
// 记录上一次的分页状态
|
|
||||||
const prevActiveTabRef = React.useRef(activeTab);
|
|
||||||
|
|
||||||
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
|
|
||||||
useEffect(() => {
|
|
||||||
// 检测是否从其他分页切换到API分页
|
|
||||||
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
|
|
||||||
fetchProfiles();
|
|
||||||
}
|
|
||||||
// 更新上一次的分页状态
|
|
||||||
prevActiveTabRef.current = activeTab;
|
|
||||||
}, [activeTab, fetchProfiles]);
|
|
||||||
|
|
||||||
// 当选中配置文件时,加载该配置
|
// 当选中配置文件时,加载该配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProfileId) {
|
if (selectedProfileId) {
|
||||||
@@ -100,6 +90,15 @@ const ApiConfig = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedProfileId]);
|
}, [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) => {
|
const loadProfile = async (profileId) => {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +122,11 @@ const ApiConfig = () => {
|
|||||||
const { name, value, type, checked } = e.target;
|
const { name, value, type, checked } = e.target;
|
||||||
const newValue = type === 'checkbox' ? checked : value;
|
const newValue = type === 'checkbox' ? checked : value;
|
||||||
|
|
||||||
|
// 如果是 API Key 字段,标记为正在编辑
|
||||||
|
if (name === 'apiKey') {
|
||||||
|
setIsEditingApiKey(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (path) {
|
if (path) {
|
||||||
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
|
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
@@ -267,8 +271,10 @@ const ApiConfig = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentApi.apiKey) {
|
// 检查 API Key:可以为空(如果已保存过,后端会使用保存的 key)
|
||||||
alert('请先填写 API 密钥');
|
// 但如果既没有输入 key,也没有保存过,则会失败
|
||||||
|
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
|
||||||
|
alert('请先填写 API 密钥,或先保存配置文件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +283,7 @@ const ApiConfig = () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
id: selectedProfileId || null, // 使用 id 字段传递 profileId
|
||||||
apiUrl: currentApi.apiUrl,
|
apiUrl: currentApi.apiUrl,
|
||||||
apiKey: currentApi.apiKey,
|
apiKey: currentApi.apiKey,
|
||||||
category: currentCategory,
|
category: currentCategory,
|
||||||
@@ -388,19 +395,9 @@ const ApiConfig = () => {
|
|||||||
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
|
||||||
await deleteProfile(selectedProfileId);
|
await deleteProfile(selectedProfileId);
|
||||||
setSelectedProfileId('');
|
setSelectedProfileId('');
|
||||||
handleCreateNew();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理设为默认配置
|
|
||||||
const handleSetActive = async () => {
|
|
||||||
if (!selectedProfileId) {
|
|
||||||
alert('请先保存配置文件');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setActiveConfig(currentCategory, selectedProfileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 配置区域标签(简短名称 + Tooltip)
|
// 配置区域标签(简短名称 + Tooltip)
|
||||||
const configTabs = [
|
const configTabs = [
|
||||||
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
|
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
|
||||||
@@ -409,8 +406,13 @@ const ApiConfig = () => {
|
|||||||
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
|
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// 判断当前 API 密钥是否是脱敏的
|
// 脱敏 API Key 显示(只显示最后6位)
|
||||||
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
|
const maskApiKey = (apiKey) => {
|
||||||
|
if (!apiKey || apiKey.length <= 6) {
|
||||||
|
return apiKey || '';
|
||||||
|
}
|
||||||
|
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
|
||||||
|
};
|
||||||
|
|
||||||
// 计算有修改的 API 数量
|
// 计算有修改的 API 数量
|
||||||
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
|
||||||
@@ -438,40 +440,37 @@ const ApiConfig = () => {
|
|||||||
onChange={handleSelectProfile}
|
onChange={handleSelectProfile}
|
||||||
className="form-control profile-select-input"
|
className="form-control profile-select-input"
|
||||||
>
|
>
|
||||||
<option value="">新建配置文件...</option>
|
{profiles.length === 0 ? (
|
||||||
|
<option value="">暂无配置文件</option>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="">-- 选择配置文件 --</option>
|
||||||
{profiles.map(profile => (
|
{profiles.map(profile => (
|
||||||
<option key={profile.id} value={profile.id}>
|
<option key={profile.id} value={profile.id}>
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<div className="profile-buttons">
|
<div className="profile-buttons">
|
||||||
{!selectedProfileId ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
onClick={handleOpenSaveModal}
|
onClick={handleOpenSaveModal}
|
||||||
|
title="创建新的配置文件"
|
||||||
>
|
>
|
||||||
+ 新建
|
+ 新建
|
||||||
</button>
|
</button>
|
||||||
) : (
|
{selectedProfileId && (
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
onClick={handleSetActive}
|
|
||||||
disabled={activeMap[currentCategory] === selectedProfileId}
|
|
||||||
>
|
|
||||||
设为默认
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger btn-sm"
|
className="btn btn-danger btn-sm"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
title="删除当前配置文件"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,13 +605,31 @@ const ApiConfig = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="cloud-apiKey">API Key</label>
|
<label htmlFor="cloud-apiKey">API Key</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={isEditingApiKey ? 'text' : 'password'}
|
||||||
id="cloud-apiKey"
|
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'])}
|
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"
|
className="form-control"
|
||||||
/>
|
/>
|
||||||
|
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -668,17 +685,29 @@ const ApiConfig = () => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="apiKey">密钥</label>
|
<label htmlFor="apiKey">密钥</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={isEditingApiKey ? 'text' : 'password'}
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
|
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
|
||||||
onChange={handleChange}
|
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"
|
className="form-control"
|
||||||
/>
|
/>
|
||||||
{isApiKeyMasked && (
|
<span className="form-hint">首次输入可见,之后仅显示最后6位</span>
|
||||||
<span className="form-hint">当前密钥已加密存储</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 6px;
|
padding: 0; /* sidebar-content已有padding,这里不需要 */
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -52,6 +52,13 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 激活状态的按钮 */
|
||||||
|
.action-btn.active {
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tag 筛选器 */
|
/* Tag 筛选器 */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -69,6 +76,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-btn:hover {
|
.tag-btn:hover {
|
||||||
@@ -76,12 +86,36 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-btn.active {
|
/* 包含模式(第一次点击)- 绿色 */
|
||||||
background: var(--color-accent);
|
.tag-btn.tag-include {
|
||||||
border-color: var(--color-accent);
|
background: #10b981;
|
||||||
|
border-color: #10b981;
|
||||||
color: white;
|
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,
|
.error-message,
|
||||||
.loading-message,
|
.loading-message,
|
||||||
@@ -101,17 +135,18 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); /* 减小最小宽度,让窄屏也能显示2列 */
|
||||||
gap: 6px;
|
gap: 8px; /* 增加间距,让卡片更有呼吸感 */
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
align-content: start; /* ✅ 从上到下排列,不留空白 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-item {
|
.character-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 140px; /* 固定宽度 */
|
width: 100%; /* 改为自适应宽度 */
|
||||||
height: 200px; /* 固定高度 */
|
height: 170px; /* ✅ 固定高度,不随空间浮动 */
|
||||||
background: var(--color-bg-secondary);
|
background: var(--color-bg-secondary);
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -126,6 +161,19 @@
|
|||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ✅ 无图模式 - 缩小卡片 */
|
||||||
|
.character-item.no-image {
|
||||||
|
height: 80px; /* 减小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-item.no-image .character-avatar-wrapper {
|
||||||
|
display: none; /* 隐藏图片区域 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-item.no-image .character-info {
|
||||||
|
padding: 8px; /* 增加内边距 */
|
||||||
|
}
|
||||||
|
|
||||||
.character-item:hover {
|
.character-item:hover {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -140,7 +188,7 @@
|
|||||||
/* 角色头像 */
|
/* 角色头像 */
|
||||||
.character-avatar-wrapper {
|
.character-avatar-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3/4; /* 保持3:4比例 */
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -170,24 +218,24 @@
|
|||||||
|
|
||||||
/* 角色信息 */
|
/* 角色信息 */
|
||||||
.character-info {
|
.character-info {
|
||||||
padding: 6px;
|
padding: 5px; /* 减小padding,从6px降到5px */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 50px;
|
min-height: 40px; /* 减小最小高度,从50px降到40px */
|
||||||
/* 移除 user-select,因为已经在父元素设置了 */
|
/* 移除 user-select,因为已经在父元素设置了 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-name {
|
.character-name {
|
||||||
font-size: 12px;
|
font-size: 11px; /* 稍微减小字体,从12px降到11px */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 2px; /* 减小间距 */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-desc {
|
.character-desc {
|
||||||
font-size: 10px;
|
font-size: 9px; /* 减小字体,从10px降到9px */
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -200,7 +248,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-top: 3px;
|
margin-top: 2px; /* 减小间距,从3px降到2px */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
@@ -270,39 +318,117 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px;
|
padding: 6px 10px;
|
||||||
background: var(--color-bg-tertiary);
|
background: var(--color-bg-tertiary);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--color-border-light);
|
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 {
|
.edit-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
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;
|
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;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-format-selector {
|
.export-format-selector {
|
||||||
padding: 4px 8px;
|
padding: 3px 6px;
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-format-selector:hover {
|
.export-format-selector:hover {
|
||||||
@@ -314,55 +440,78 @@
|
|||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.toolbar-btn {
|
||||||
padding: 4px 10px;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.15s ease;
|
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);
|
background: var(--color-accent);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.save:hover {
|
.toolbar-btn.save:hover {
|
||||||
background: var(--color-accent-dark);
|
background: var(--color-accent-dark);
|
||||||
border-color: var(--color-accent-dark);
|
border-color: var(--color-accent-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.export {
|
.toolbar-btn.export {
|
||||||
background: #10b981;
|
background: #10b981;
|
||||||
border-color: #10b981;
|
border-color: #10b981;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.export:hover {
|
.toolbar-btn.export:hover {
|
||||||
background: #059669;
|
background: #059669;
|
||||||
border-color: #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;
|
background: #ef4444;
|
||||||
border-color: #ef4444;
|
border-color: #ef4444;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.delete:hover {
|
.toolbar-btn.delete:hover {
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
border-color: #dc2626;
|
border-color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.cancel {
|
.toolbar-btn.cancel {
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn.cancel:hover {
|
.toolbar-btn.cancel:hover {
|
||||||
background: var(--color-error);
|
background: var(--color-error);
|
||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -407,6 +556,68 @@
|
|||||||
.form-group textarea {
|
.form-group textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 60px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: var(--z-modal-overlay); /* ✅ 弹窗层 - 遮罩 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-selector-modal {
|
.chat-selector-modal {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
1// frontend-react/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx
|
||||||
import React, { useState, useEffect, useRef, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
|
||||||
import { useCharacterStore } from '../../../../Store/SideBarLeft';
|
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
|
||||||
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
|
||||||
|
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
|
||||||
|
import useSideBarLeftStore from '../../../../Store/SideBarLeft/SideBarLeftSlice'; // ✅ 引入侧边栏 Store
|
||||||
import './CharacterCard.css';
|
import './CharacterCard.css';
|
||||||
|
|
||||||
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
|
||||||
const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
const CharacterItem = memo(({ character, isSelected, onSelect, showImages }) => {
|
||||||
const handleImageError = useCallback((e) => {
|
const handleImageError = useCallback((e) => {
|
||||||
e.target.style.display = 'none';
|
e.target.style.display = 'none';
|
||||||
e.target.nextSibling.style.display = 'flex';
|
e.target.nextSibling.style.display = 'flex';
|
||||||
@@ -24,7 +26,7 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`character-item ${isSelected ? 'selected' : ''}`}
|
className={`character-item ${isSelected ? 'selected' : ''} ${!showImages ? 'no-image' : ''}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -54,8 +56,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
|||||||
)}
|
)}
|
||||||
{character.tags && character.tags.length > 0 && (
|
{character.tags && character.tags.length > 0 && (
|
||||||
<div className="character-tags">
|
<div className="character-tags">
|
||||||
{character.tags.slice(0, 3).map(tag => (
|
{character.tags.slice(0, 3).map((tag, index) => (
|
||||||
<span key={tag} className="tag">{tag}</span>
|
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +67,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
|
|||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// 自定义比较函数,只在必要时重新渲染
|
// 自定义比较函数,只在必要时重新渲染
|
||||||
return prevProps.character.id === nextProps.character.id &&
|
return prevProps.character.id === nextProps.character.id &&
|
||||||
prevProps.isSelected === nextProps.isSelected;
|
prevProps.isSelected === nextProps.isSelected &&
|
||||||
|
prevProps.showImages === nextProps.showImages;
|
||||||
});
|
});
|
||||||
|
|
||||||
CharacterItem.displayName = 'CharacterItem';
|
CharacterItem.displayName = 'CharacterItem';
|
||||||
@@ -76,30 +79,45 @@ const CharacterCard = () => {
|
|||||||
selectedCharacter,
|
selectedCharacter,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
currentPage,
|
|
||||||
pageSize,
|
|
||||||
characterChats,
|
characterChats,
|
||||||
fetchCharacters,
|
fetchCharacters,
|
||||||
selectCharacter,
|
selectCharacter,
|
||||||
deleteCharacter,
|
deleteCharacter,
|
||||||
exportCharacterAsPng,
|
exportCharacterAsPng,
|
||||||
setCurrentPage,
|
|
||||||
setPageSize,
|
|
||||||
getCurrentPageCharacters,
|
|
||||||
getTotalPages,
|
|
||||||
fetchCharacterChats
|
fetchCharacterChats
|
||||||
} = useCharacterStore();
|
} = useCharacterStore();
|
||||||
|
|
||||||
const [filterTag, setFilterTag] = useState('');
|
// ✅ 从 CharacterCardUIStore 获取 UI 状态
|
||||||
const [isEditing, setIsEditing] = useState(false); // 是否处于编辑模式
|
const {
|
||||||
const [editForm, setEditForm] = useState(null); // 编辑表单数据
|
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 [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
|
||||||
|
const [showImages, setShowImages] = useState(true); // ✅ 是否显示图片
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const clickTimeoutRef = useRef(null);
|
const clickTimeoutRef = useRef(null);
|
||||||
|
|
||||||
// 加载角色列表
|
// 加载角色列表和世界书列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCharacters();
|
fetchCharacters();
|
||||||
|
fetchWorldBooks(); // 加载世界书列表
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
@@ -112,16 +130,37 @@ const CharacterCard = () => {
|
|||||||
// 获取所有唯一的 tags
|
// 获取所有唯一的 tags
|
||||||
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
|
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
|
||||||
|
|
||||||
// 过滤角色
|
// 过滤角色(支持多标签交集筛选)
|
||||||
const filteredCharacters = filterTag
|
const filteredCharacters = React.useMemo(() => {
|
||||||
? characters.filter(char => (char.tags || []).includes(filterTag))
|
if (filterTags.length === 0) return characters;
|
||||||
: characters;
|
|
||||||
|
|
||||||
// 获取当前页的角色数据
|
// 分离包含和排除标签
|
||||||
const currentPageCharacters = getCurrentPageCharacters();
|
const includeTags = filterTags
|
||||||
|
.filter(f => f.startsWith('include:'))
|
||||||
|
.map(f => f.substring(8));
|
||||||
|
|
||||||
// 获取总页数
|
const excludeTags = filterTags
|
||||||
const totalPages = getTotalPages();
|
.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) => {
|
const handleImport = async (event) => {
|
||||||
@@ -151,6 +190,27 @@ const CharacterCard = () => {
|
|||||||
const name = prompt('请输入角色名称:');
|
const name = prompt('请输入角色名称:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
|
// ✅ 询问历史记录模式
|
||||||
|
const historyMode = prompt(
|
||||||
|
'请选择历史记录模式:\n' +
|
||||||
|
'1. 全量模式(保留所有消息)\n' +
|
||||||
|
'2. 总结模式(定期总结历史)\n' +
|
||||||
|
'3. RAG模式(需要API配置,选择后不可取消)\n\n' +
|
||||||
|
'请输入数字 (1/2/3):'
|
||||||
|
);
|
||||||
|
|
||||||
|
let mode = 'full';
|
||||||
|
if (historyMode === '2') mode = 'summary';
|
||||||
|
else if (historyMode === '3') mode = 'rag';
|
||||||
|
|
||||||
|
if (mode === 'rag') {
|
||||||
|
const confirm = window.confirm(
|
||||||
|
'⚠️ RAG模式需要配置API,且选择后不可取消。\n' +
|
||||||
|
'是否继续?'
|
||||||
|
);
|
||||||
|
if (!confirm) return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { createCharacter } = useCharacterStore.getState();
|
const { createCharacter } = useCharacterStore.getState();
|
||||||
await createCharacter({
|
await createCharacter({
|
||||||
@@ -161,7 +221,8 @@ const CharacterCard = () => {
|
|||||||
first_mes: '',
|
first_mes: '',
|
||||||
mes_example: '',
|
mes_example: '',
|
||||||
categories: [],
|
categories: [],
|
||||||
tags: []
|
tags: [],
|
||||||
|
historyMode: mode // ✅ 添加历史记录模式
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('创建失败:', err);
|
console.error('创建失败:', err);
|
||||||
@@ -176,6 +237,12 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteCharacter(name);
|
await deleteCharacter(name);
|
||||||
|
|
||||||
|
// ✅ 删除成功后自动切换到角色分页
|
||||||
|
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||||
|
setActiveTab('character');
|
||||||
|
|
||||||
|
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('删除失败:', 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) => {
|
const handleSelectCharacter = useCallback(async (character) => {
|
||||||
console.log('[CharacterCard] 点击角色:', character.name);
|
console.log('[CharacterCard] 点击角色:', character.name);
|
||||||
@@ -247,17 +370,7 @@ const CharacterCard = () => {
|
|||||||
selectCharacter(character);
|
selectCharacter(character);
|
||||||
|
|
||||||
// 进入编辑模式
|
// 进入编辑模式
|
||||||
setIsEditing(true);
|
startEditing(character); // ✅ 使用 store 方法
|
||||||
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 || []
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取该角色的聊天列表,并自动切换到最后一个聊天
|
// 获取该角色的聊天列表,并自动切换到最后一个聊天
|
||||||
try {
|
try {
|
||||||
@@ -267,38 +380,19 @@ const CharacterCard = () => {
|
|||||||
if (chats.length > 0) {
|
if (chats.length > 0) {
|
||||||
// 有聊天记录,切换到最后一个
|
// 有聊天记录,切换到最后一个
|
||||||
const lastChat = chats[chats.length - 1].chat_name;
|
const lastChat = chats[chats.length - 1].chat_name;
|
||||||
|
|
||||||
|
// 先设置角色和聊天,这会触发 ChatBoxStore 的监听器自动加载聊天历史
|
||||||
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
|
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, lastChat);
|
||||||
|
|
||||||
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
|
console.log(`[CharacterCard] 已切换到角色 ${character.name} 的聊天: ${lastChat}`);
|
||||||
} else {
|
} else {
|
||||||
// 没有聊天记录,自动创建一个默认聊天
|
// 没有聊天记录,只设置角色,不创建聊天文件
|
||||||
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,创建默认聊天...`);
|
// 聊天文件将在用户发送第一条消息时创建
|
||||||
|
console.log(`[CharacterCard] 角色 ${character.name} 没有聊天,等待用户发送第一条消息...`);
|
||||||
|
|
||||||
const defaultChatName = '默认聊天';
|
// 只设置角色,不设置聊天(currentChat 为 null)
|
||||||
|
// ChatBox 会显示开场白(临时消息),但不会保存到文件
|
||||||
try {
|
useChatBoxStore.getState().setChatBoxRoleAndChat(character.name, null);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,8 +406,7 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
// 退出编辑模式
|
// 退出编辑模式
|
||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setIsEditing(false);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditForm(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存编辑
|
// 保存编辑
|
||||||
@@ -323,8 +416,7 @@ const CharacterCard = () => {
|
|||||||
try {
|
try {
|
||||||
const { updateCharacter } = useCharacterStore.getState();
|
const { updateCharacter } = useCharacterStore.getState();
|
||||||
await updateCharacter(selectedCharacter.name, editForm);
|
await updateCharacter(selectedCharacter.name, editForm);
|
||||||
setIsEditing(false);
|
cancelEditing(); // ✅ 使用 store 方法
|
||||||
setEditForm(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('保存失败:', err);
|
console.error('保存失败:', err);
|
||||||
alert('保存失败: ' + err.message);
|
alert('保存失败: ' + err.message);
|
||||||
@@ -333,10 +425,14 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
// 处理表单字段变化
|
// 处理表单字段变化
|
||||||
const handleFormChange = (field, value) => {
|
const handleFormChange = (field, value) => {
|
||||||
setEditForm(prev => ({
|
updateEditForm(field, value); // ✅ 使用 store 方法
|
||||||
...prev,
|
};
|
||||||
[field]: value
|
|
||||||
}));
|
// 自动调整 textarea 高度
|
||||||
|
const autoResizeTextarea = (e) => {
|
||||||
|
const textarea = e.target;
|
||||||
|
textarea.style.height = 'auto'; // 重置高度
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度,最大300px
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -350,6 +446,14 @@ const CharacterCard = () => {
|
|||||||
<div className="tab-actions">
|
<div className="tab-actions">
|
||||||
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
<button className="action-btn" onClick={handleCreate}>+ 新建</button>
|
||||||
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
<button className="action-btn" onClick={triggerImport}>📥 导入</button>
|
||||||
|
{/* ✅ 切换图片显示 */}
|
||||||
|
<button
|
||||||
|
className={`action-btn ${showImages ? '' : 'active'}`}
|
||||||
|
onClick={() => setShowImages(!showImages)}
|
||||||
|
title={showImages ? '隐藏图片' : '显示图片'}
|
||||||
|
>
|
||||||
|
{showImages ? '🖼️' : '🚫'}
|
||||||
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -363,20 +467,93 @@ const CharacterCard = () => {
|
|||||||
{allTags.length > 0 && (
|
{allTags.length > 0 && (
|
||||||
<div className="tag-filter">
|
<div className="tag-filter">
|
||||||
<button
|
<button
|
||||||
className={`tag-btn ${!filterTag ? 'active' : ''}`}
|
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
|
||||||
onClick={() => setFilterTag('')}
|
onClick={clearAllFilters}
|
||||||
>
|
>
|
||||||
全部
|
全部
|
||||||
</button>
|
</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
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
|
className={`tag-btn tag-${status}`}
|
||||||
onClick={() => setFilterTag(tag)}
|
onClick={() => toggleFilterTag(tag)}
|
||||||
|
title={
|
||||||
|
status === 'include' ? '包含该标签(再次点击排除)' :
|
||||||
|
status === 'exclude' ? '排除该标签(再次点击取消)' :
|
||||||
|
'筛选包含该标签的角色'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{status === 'exclude' && <span className="tag-prefix">¬</span>}
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -397,21 +574,47 @@ const CharacterCard = () => {
|
|||||||
{/* 编辑模式 */}
|
{/* 编辑模式 */}
|
||||||
{isEditing && selectedCharacter && editForm && (
|
{isEditing && selectedCharacter && editForm && (
|
||||||
<div className="character-edit-panel">
|
<div className="character-edit-panel">
|
||||||
<div className="edit-header">
|
{/* 悬浮工具栏 */}
|
||||||
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
|
<div className="floating-toolbar">
|
||||||
<div className="edit-actions">
|
<div className="toolbar-content">
|
||||||
|
<div className="toolbar-left">
|
||||||
|
<span className="edit-title">编辑: {selectedCharacter.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-actions">
|
||||||
<select
|
<select
|
||||||
className="export-format-selector"
|
className="export-format-selector"
|
||||||
value={exportFormat}
|
value={exportFormat}
|
||||||
onChange={(e) => setExportFormat(e.target.value)}
|
onChange={(e) => setExportFormat(e.target.value)}
|
||||||
|
title="选择导出格式"
|
||||||
>
|
>
|
||||||
<option value="png">🖼️ 图片</option>
|
<option value="png">🖼️ PNG</option>
|
||||||
<option value="json">📄 JSON</option>
|
<option value="json">📄 JSON</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
|
{/* ✅ 复制角色按钮 */}
|
||||||
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑️ 删除</button>
|
<button
|
||||||
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
|
className="toolbar-btn duplicate"
|
||||||
<button className="edit-btn cancel" onClick={handleCancelEdit}>❌ 取消</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,13 +627,18 @@ const CharacterCard = () => {
|
|||||||
onChange={(e) => handleFormChange('name', e.target.value)}
|
onChange={(e) => handleFormChange('name', e.target.value)}
|
||||||
placeholder="角色名称"
|
placeholder="角色名称"
|
||||||
/>
|
/>
|
||||||
|
<small className="form-hint">💡 提示:修改角色名将同步重命名文件夹和所有相关引用</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>描述</label>
|
<label>描述</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.description}
|
value={editForm.description}
|
||||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
onChange={(e) => {
|
||||||
|
handleFormChange('description', e.target.value);
|
||||||
|
autoResizeTextarea(e);
|
||||||
|
}}
|
||||||
|
onInput={autoResizeTextarea}
|
||||||
placeholder="角色描述"
|
placeholder="角色描述"
|
||||||
rows={6}
|
rows={6}
|
||||||
/>
|
/>
|
||||||
@@ -440,7 +648,11 @@ const CharacterCard = () => {
|
|||||||
<label>性格</label>
|
<label>性格</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.personality}
|
value={editForm.personality}
|
||||||
onChange={(e) => handleFormChange('personality', e.target.value)}
|
onChange={(e) => {
|
||||||
|
handleFormChange('personality', e.target.value);
|
||||||
|
autoResizeTextarea(e);
|
||||||
|
}}
|
||||||
|
onInput={autoResizeTextarea}
|
||||||
placeholder="角色性格"
|
placeholder="角色性格"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -450,7 +662,11 @@ const CharacterCard = () => {
|
|||||||
<label>场景</label>
|
<label>场景</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.scenario}
|
value={editForm.scenario}
|
||||||
onChange={(e) => handleFormChange('scenario', e.target.value)}
|
onChange={(e) => {
|
||||||
|
handleFormChange('scenario', e.target.value);
|
||||||
|
autoResizeTextarea(e);
|
||||||
|
}}
|
||||||
|
onInput={autoResizeTextarea}
|
||||||
placeholder="场景设定"
|
placeholder="场景设定"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -460,7 +676,11 @@ const CharacterCard = () => {
|
|||||||
<label>开场白</label>
|
<label>开场白</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.first_mes}
|
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="第一条消息"
|
placeholder="第一条消息"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
@@ -470,7 +690,11 @@ const CharacterCard = () => {
|
|||||||
<label>对话示例</label>
|
<label>对话示例</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.mes_example}
|
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="对话示例"
|
placeholder="对话示例"
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
@@ -485,6 +709,151 @@ const CharacterCard = () => {
|
|||||||
placeholder="标签1, 标签2, 标签3"
|
placeholder="标签1, 标签2, 标签3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -495,7 +864,7 @@ const CharacterCard = () => {
|
|||||||
<div className="character-list">
|
<div className="character-list">
|
||||||
{currentPageCharacters.length === 0 ? (
|
{currentPageCharacters.length === 0 ? (
|
||||||
<div className="empty-message">
|
<div className="empty-message">
|
||||||
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
currentPageCharacters.map(char => (
|
currentPageCharacters.map(char => (
|
||||||
@@ -504,6 +873,7 @@ const CharacterCard = () => {
|
|||||||
character={char}
|
character={char}
|
||||||
isSelected={selectedCharacter?.id === char.id}
|
isSelected={selectedCharacter?.id === char.id}
|
||||||
onSelect={handleSelectCharacter}
|
onSelect={handleSelectCharacter}
|
||||||
|
showImages={showImages}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -514,7 +884,7 @@ const CharacterCard = () => {
|
|||||||
<div className="pagination-controls">
|
<div className="pagination-controls">
|
||||||
<button
|
<button
|
||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={prevPage} // ✅ 使用 store 方法
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
@@ -526,7 +896,7 @@ const CharacterCard = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="pagination-btn"
|
className="pagination-btn"
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={nextPage} // ✅ 使用 store 方法
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
@@ -535,12 +905,12 @@ const CharacterCard = () => {
|
|||||||
<select
|
<select
|
||||||
className="page-size-selector"
|
className="page-size-selector"
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
onChange={(e) => setPageSize(Number(e.target.value))} // ✅ 使用 store 方法
|
||||||
>
|
>
|
||||||
<option value={8}>8条/页</option>
|
<option key="8" value={8}>8条/页</option>
|
||||||
<option value={12}>12条/页</option>
|
<option key="12" value={12}>12条/页</option>
|
||||||
<option value={20}>20条/页</option>
|
<option key="20" value={20}>20条/页</option>
|
||||||
<option value={50}>50条/页</option>
|
<option key="50" value={50}>50条/页</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user