Compare commits
35 Commits
18ee2b9b2c
...
fix/世界书读取出
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e7e75ffb | |||
| 6b65b24b0f | |||
| 1d0f0ae0ef | |||
| ba9b925c32 | |||
| a3e3711b2b | |||
| 8b10ef5828 | |||
| dd17206e1f | |||
| 4f9cf4b725 | |||
| e8dedb5ec4 | |||
| 7a62139683 | |||
| 01ca2bd0f9 | |||
| 1fc0c43689 | |||
| 1abfaeda9d | |||
| 0ae53c4b81 | |||
| f90ad8dc13 | |||
| 6375f9759c | |||
| 33188a345e | |||
| 6fa1fd6e7f | |||
| 80237463ef | |||
| f9bc77d392 | |||
| 408e9ce569 | |||
| 60a2049bb7 | |||
| 4d4d7c30ce | |||
| 2b1ec63c00 | |||
| 6c74bef8da | |||
| 73cdf5ac23 | |||
| a371039ee6 | |||
| 91d11abe90 | |||
| 4b85b35cf8 | |||
| c99052529d | |||
| bd1fa14f20 | |||
| 1aa90f5acf | |||
| 04ea889d75 | |||
| 3c4a11eca8 | |||
| 85f2bbe78c |
5
.env
5
.env
@@ -9,3 +9,8 @@ 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
|
||||||
|
|||||||
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# ==================== Python ====================
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
VENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# ==================== Node.js ====================
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Frontend build
|
||||||
|
frontend/dist/
|
||||||
|
frontend/dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# ==================== Environment variables ====================
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
!.env.development
|
||||||
|
!.env.production
|
||||||
|
|
||||||
|
# ==================== Logs ====================
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
log/
|
||||||
|
|
||||||
|
# ==================== Data files ====================
|
||||||
|
# 保留目录结构,忽略数据文件
|
||||||
|
data/chat/**/*.jsonl
|
||||||
|
data/chat/**/*.json
|
||||||
|
data/preset/*.json
|
||||||
|
data/worldbooks/*.json
|
||||||
|
data/apiconfig/*.json
|
||||||
|
data/comfyui_workflows/*.json
|
||||||
|
data/images/*
|
||||||
|
data/temp/*
|
||||||
|
outputs/*
|
||||||
|
imports/*
|
||||||
|
|
||||||
|
# ==================== Docker ====================
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# ==================== Temporary files ====================
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# ==================== Test coverage ====================
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# ==================== Misc ====================
|
||||||
|
.parcel-cache/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.vuepress/dist/
|
||||||
|
.serverless/
|
||||||
|
.fusebox/
|
||||||
|
.dynamodb/
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# ==================== Project specific ====================
|
||||||
|
# Backend output
|
||||||
|
backend/__pycache__/
|
||||||
|
backend/api/__pycache__/
|
||||||
|
backend/api/routes/__pycache__/
|
||||||
|
backend/core/__pycache__/
|
||||||
|
backend/services/__pycache__/
|
||||||
|
backend/utils/__pycache__/
|
||||||
|
|
||||||
|
# Claude settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*~
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
2
.idea/llm-workflow-engine.iml
generated
2
.idea/llm-workflow-engine.iml
generated
@@ -3,6 +3,8 @@
|
|||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/backend/api" isTestSource="false" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
195
FIX_GLOBAL_WORLDBOOK_SYNC.md
Normal file
195
FIX_GLOBAL_WORLDBOOK_SYNC.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# 全局世界书数据同步问题修复
|
||||||
|
|
||||||
|
## 🐛 问题描述
|
||||||
|
|
||||||
|
**现象**: 当世界书文件被删除后,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与后端数据保持一致
|
||||||
|
✅ **用户体验**: 不再显示"幽灵"世界书
|
||||||
|
✅ **代码健壮**: 增加了数据一致性检查机制
|
||||||
226
README.md
Normal file
226
README.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# LLM Workflow Engine
|
||||||
|
|
||||||
|
一个基于 React + TypeScript + FastAPI 的 AI 聊天工作流引擎,支持流式对话、动态表格生成、图片生成等功能。
|
||||||
|
|
||||||
|
## 🚀 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **React 18** - 用户界面框架
|
||||||
|
- **TypeScript** - 类型安全的 JavaScript
|
||||||
|
- **Vite** - 现代化的前端构建工具
|
||||||
|
- **Zustand** - 轻量级状态管理
|
||||||
|
- **React Markdown** - Markdown 渲染
|
||||||
|
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **FastAPI** - 现代化的 Python Web 框架
|
||||||
|
- **Python 3.11** - 编程语言
|
||||||
|
- **Uvicorn** - ASGI 服务器
|
||||||
|
- **WebSockets** - 实时通信
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
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 # 项目文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ 安装和运行
|
||||||
|
|
||||||
|
### 使用 Docker Compose(推荐)
|
||||||
|
|
||||||
|
这是最简单的运行方式,适合开发和生产环境。
|
||||||
|
|
||||||
|
1. **克隆项目**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd llm_workflow_engine
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **配置环境变量**
|
||||||
|
```bash
|
||||||
|
# 复制环境变量模板
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 根据需要编辑 .env 文件
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
# 构建并启动所有服务
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# 或者在后台运行
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **访问应用**
|
||||||
|
- 前端界面: http://localhost:23338
|
||||||
|
- 后端 API: http://localhost:23337
|
||||||
|
- API 文档: http://localhost:23337/docs
|
||||||
|
|
||||||
|
5. **停止服务**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
如果你想分别运行前后端进行开发:
|
||||||
|
|
||||||
|
#### 后端开发
|
||||||
|
|
||||||
|
1. **安装 Python 依赖**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动后端服务**
|
||||||
|
```bash
|
||||||
|
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 前端开发
|
||||||
|
|
||||||
|
1. **安装 Node.js 依赖**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **启动前端开发服务器**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **访问应用**
|
||||||
|
- 前端界面: http://localhost:5173
|
||||||
|
- 确保后端在 http://localhost:8000 运行
|
||||||
|
|
||||||
|
## 🔧 配置说明
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
#### 前端环境变量 (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
|
||||||
|
# 构建并启动
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# 后台运行
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# 停止服务
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
docker-compose exec backend bash
|
||||||
|
docker-compose exec frontend sh
|
||||||
|
|
||||||
|
# 清理所有容器和卷
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 开发工具
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
```bash
|
||||||
|
# 类型检查
|
||||||
|
npm run type-check
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 预览生产构建
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
```bash
|
||||||
|
# 运行测试(如果有的话)
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# 代码格式化
|
||||||
|
black .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 待办事项
|
||||||
|
|
||||||
|
- [ ] 添加单元测试
|
||||||
|
- [ ] 完善错误处理
|
||||||
|
- [ ] 添加用户认证
|
||||||
|
- [ ] 优化性能
|
||||||
|
- [ ] 添加更多语言支持
|
||||||
|
- [ ] 完善文档
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
如有问题,请提交 Issue 或联系维护者。
|
||||||
194
REMOVE_CONFIRM_DIALOG.md
Normal file
194
REMOVE_CONFIRM_DIALOG.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# 世界书删除确认弹窗移除
|
||||||
|
|
||||||
|
## 📋 修改内容
|
||||||
|
|
||||||
|
移除了世界书模块中所有的浏览器级别确认弹窗(`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组件而非浏览器原生弹窗。
|
||||||
428
TEST_API_CONFIG.md
Normal file
428
TEST_API_CONFIG.md
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
# 🧪 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**祝测试顺利!** 🎉
|
||||||
217
WORLDBOOK_API_CHECK.md
Normal file
217
WORLDBOOK_API_CHECK.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# 世界书分页功能 - 前端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**
|
||||||
|
|
||||||
|
- 下拉框正确读取所有世界书
|
||||||
|
- 分页参数正确传递
|
||||||
|
- 数据格式匹配
|
||||||
|
- 错误处理完善
|
||||||
|
- 用户体验流畅
|
||||||
@@ -4,21 +4,24 @@ FROM python:3.11-slim
|
|||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# 复制依赖文件
|
# 复制依赖文件
|
||||||
# 注意:这里的 requirements.txt 在 backend/ 目录下
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
# 安装依赖
|
# 安装依赖
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 安装 Pillow(使用阿里云镜像源)
|
||||||
|
RUN pip install --no-cache-dir Pillow -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com || echo "Pillow installation failed, will install manually"
|
||||||
|
|
||||||
# 复制所有代码
|
# 复制所有代码
|
||||||
# 关键修改:把 backend/ 目录下的内容复制到 /app/backend/ 下
|
COPY . .
|
||||||
# 这样镜像内的结构就是 /app/backend/app/...
|
|
||||||
COPY . ./backend/
|
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# 启动命令
|
# 启动命令
|
||||||
# 关键修改:路径改为 backend.app.api.route
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
CMD ["uvicorn", "backend.app.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
|
|||||||
BIN
backend/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
backend/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
@@ -1,18 +1,20 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
# 假设这些函数已经在其他地方定义
|
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
|
||||||
from backend.core.items import ChatRequest
|
from utils.file_utils import get_all_roles_and_chats
|
||||||
from backend.tools.get_all_role_and_chat import get_all_role_and_chat as get_chat_file
|
from core.config import settings
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
app = FastAPI()
|
router = APIRouter()
|
||||||
|
|
||||||
# 1. 将输入内容持久化存储到本地jsonl方便前端读
|
# 注册子路由
|
||||||
@app.post("/generate_reply")
|
router.include_router(presetsRoute.router)
|
||||||
async def save_input_to_json(chat_request: ChatRequest):
|
router.include_router(chatsRoute.router)
|
||||||
return 0
|
router.include_router(worldbooksRoute.router)
|
||||||
|
router.include_router(apiConfigRoute.router)
|
||||||
|
router.include_router(charactersRoute.router)
|
||||||
|
|
||||||
# 2. 从本地jsonl中读取历史对话
|
|
||||||
@app.get("/get_all_role_and_chat")
|
# 保留原有的其他路由
|
||||||
def get_all_role_and_chat():
|
@router.get("/tool_bar/get_all_role_and_chat")
|
||||||
# 直接调用导入的函数
|
def get_all_role_and_chat_endpoint():
|
||||||
result = get_chat_file()
|
return get_all_roles_and_chats(Path(settings.DATA_PATH))
|
||||||
return result
|
|
||||||
|
|||||||
389
backend/api/routes/apiConfigRoute.py
Normal file
389
backend/api/routes/apiConfigRoute.py
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Dict, Optional, List, Any
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from core.config import settings
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
import base64
|
||||||
|
from services.comfyui_workflow_manager import workflow_manager
|
||||||
|
from services.llm_model_service import LLMModelService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
|
||||||
|
|
||||||
|
# 加密密钥(实际项目中应该从环境变量读取)
|
||||||
|
ENCRYPTION_KEY = os.getenv('API_ENCRYPTION_KEY', Fernet.generate_key().decode())
|
||||||
|
fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
|
||||||
|
|
||||||
|
# 配置文件路径
|
||||||
|
CONFIG_DIR = Path(settings.DATA_PATH) / "apiconfig"
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfigItem(BaseModel):
|
||||||
|
"""单个 API 配置项"""
|
||||||
|
id: Optional[str] = None
|
||||||
|
name: Optional[str] = ""
|
||||||
|
category: Optional[str] = None # mainLLM, imageModel, secondaryLLM, ragEmbedding
|
||||||
|
apiUrl: Optional[str] = ""
|
||||||
|
apiKey: Optional[str] = None # 前端传入的可能是明文或空
|
||||||
|
model: Optional[str] = ""
|
||||||
|
|
||||||
|
# 生图模型的特殊字段
|
||||||
|
mode: Optional[str] = None # 'local' | 'cloud'
|
||||||
|
local: Optional[dict] = None
|
||||||
|
cloud: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSaveRequest(BaseModel):
|
||||||
|
"""保存配置文件的请求"""
|
||||||
|
profileId: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
apis: Dict[str, ApiConfigItem] # key 是 category,value 是配置
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
"""配置文件响应(不包含明文 API Key)"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
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]:
|
||||||
|
"""加载配置文件"""
|
||||||
|
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_profile(profile_id: str, profile_data: dict):
|
||||||
|
"""保存配置文件"""
|
||||||
|
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||||
|
with open(config_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(profile_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> List[dict]:
|
||||||
|
"""列出所有配置文件"""
|
||||||
|
profiles = []
|
||||||
|
for config_file in CONFIG_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r', encoding='utf-8') as f:
|
||||||
|
profile = json.load(f)
|
||||||
|
profiles.append({
|
||||||
|
"id": profile.get("id", config_file.stem),
|
||||||
|
"name": profile.get("name", config_file.stem),
|
||||||
|
"createdAt": profile.get("createdAt", "")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles", response_model=List[dict])
|
||||||
|
def get_all_profiles():
|
||||||
|
"""获取所有配置文件列表"""
|
||||||
|
return list_profiles()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||||
|
def get_profile(profile_id: str):
|
||||||
|
"""获取单个配置文件(API Key 已脱敏)"""
|
||||||
|
profile = load_profile(profile_id)
|
||||||
|
if not profile:
|
||||||
|
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||||
|
|
||||||
|
# 脱敏所有 API Key
|
||||||
|
masked_apis = {}
|
||||||
|
for category, api_config in profile.get("apis", {}).items():
|
||||||
|
masked_config = api_config.copy()
|
||||||
|
if "apiKey" in masked_config and masked_config["apiKey"]:
|
||||||
|
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
|
||||||
|
masked_apis[category] = masked_config
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": profile.get("id", profile_id),
|
||||||
|
"name": profile.get("name", profile_id),
|
||||||
|
"apis": masked_apis
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profiles", response_model=ProfileResponse)
|
||||||
|
def create_or_update_profile(request: ProfileSaveRequest):
|
||||||
|
"""创建或更新配置文件(增量更新)"""
|
||||||
|
# 加载现有配置
|
||||||
|
existing_profile = load_profile(request.profileId)
|
||||||
|
|
||||||
|
if existing_profile:
|
||||||
|
# 更新现有配置:只更新提供的 API 配置
|
||||||
|
for category, api_config in request.apis.items():
|
||||||
|
api_config_dict = api_config.dict(exclude_none=True)
|
||||||
|
|
||||||
|
# 处理 API Key 加密
|
||||||
|
if api_config.apiKey and api_config.apiKey != "****":
|
||||||
|
# 如果是新的明文 key,加密它
|
||||||
|
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
|
||||||
|
elif api_config.apiKey == "****":
|
||||||
|
# 如果是脱敏的 key,保留原有的加密 key
|
||||||
|
if category in existing_profile.get("apis", {}):
|
||||||
|
api_config_dict["apiKey"] = existing_profile["apis"][category].get("apiKey", "")
|
||||||
|
else:
|
||||||
|
api_config_dict.pop("apiKey", None)
|
||||||
|
|
||||||
|
# 更新配置
|
||||||
|
if "apis" not in existing_profile:
|
||||||
|
existing_profile["apis"] = {}
|
||||||
|
existing_profile["apis"][category] = api_config_dict
|
||||||
|
|
||||||
|
profile_data = existing_profile
|
||||||
|
else:
|
||||||
|
# 新建配置文件
|
||||||
|
from datetime import datetime
|
||||||
|
profile_data = {
|
||||||
|
"id": request.profileId,
|
||||||
|
"name": request.name or request.profileId,
|
||||||
|
"createdAt": datetime.now().isoformat(),
|
||||||
|
"apis": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 添加所有 API 配置
|
||||||
|
for category, api_config in request.apis.items():
|
||||||
|
api_config_dict = api_config.dict(exclude_none=True)
|
||||||
|
if api_config_dict.get("apiKey"):
|
||||||
|
api_config_dict["apiKey"] = encrypt_api_key(api_config_dict["apiKey"])
|
||||||
|
profile_data["apis"][category] = api_config_dict
|
||||||
|
|
||||||
|
# 保存配置文件
|
||||||
|
save_profile(request.profileId, profile_data)
|
||||||
|
|
||||||
|
# 返回脱敏后的数据
|
||||||
|
masked_apis = {}
|
||||||
|
for category, api_config in profile_data.get("apis", {}).items():
|
||||||
|
masked_config = api_config.copy()
|
||||||
|
if "apiKey" in masked_config and masked_config["apiKey"]:
|
||||||
|
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
|
||||||
|
masked_apis[category] = masked_config
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": profile_data.get("id", request.profileId),
|
||||||
|
"name": profile_data.get("name", request.profileId),
|
||||||
|
"apis": masked_apis
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profiles/{profile_id}")
|
||||||
|
def delete_profile(profile_id: str):
|
||||||
|
"""删除配置文件"""
|
||||||
|
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||||
|
|
||||||
|
config_file.unlink()
|
||||||
|
return {"message": "配置文件已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection")
|
||||||
|
def test_connection(api_config: ApiConfigItem):
|
||||||
|
"""测试 API 连接并获取模型列表"""
|
||||||
|
try:
|
||||||
|
# 检测提供商类型
|
||||||
|
provider = LLMModelService.detect_provider(api_config.apiUrl)
|
||||||
|
|
||||||
|
# 获取模型列表
|
||||||
|
models = LLMModelService.get_models_by_provider(
|
||||||
|
provider=provider,
|
||||||
|
api_key=api_config.apiKey or "",
|
||||||
|
api_url=api_config.apiUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"models": models,
|
||||||
|
"provider": provider,
|
||||||
|
"message": f"成功获取 {len(models)} 个模型"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"获取模型列表失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== ComfyUI Workflow Management ====================
|
||||||
|
|
||||||
|
@router.get("/comfyui/workflows", response_model=List[Dict[str, Any]])
|
||||||
|
def get_comfyui_workflows():
|
||||||
|
"""获取所有可用的 ComfyUI 工作流列表"""
|
||||||
|
return workflow_manager.list_workflows()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/comfyui/workflows/upload")
|
||||||
|
async def upload_comfyui_workflow(file: UploadFile = File(...)):
|
||||||
|
"""上传 ComfyUI 工作流 JSON 文件"""
|
||||||
|
return await workflow_manager.upload_workflow(file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/comfyui/workflows/{filename}")
|
||||||
|
def delete_comfyui_workflow(filename: str):
|
||||||
|
"""删除 ComfyUI 工作流文件"""
|
||||||
|
return workflow_manager.delete_workflow(filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/comfyui/workflows/{filename}")
|
||||||
|
def get_comfyui_workflow(filename: str):
|
||||||
|
"""获取指定工作流的详细内容"""
|
||||||
|
return workflow_manager.load_workflow(filename)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Connection Testing ====================
|
||||||
|
|
||||||
|
@router.post("/test-comfyui-connection")
|
||||||
|
def test_comfyui_connection(request: dict):
|
||||||
|
"""测试 ComfyUI 连接"""
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
api_url = request.get("apiUrl", "http://comfyui:8188")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 测试基本连通性
|
||||||
|
response = req.get(f"{api_url}/system_stats", timeout=5)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"HTTP {response.status_code}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stats = response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "连接成功",
|
||||||
|
"stats": {
|
||||||
|
"vram_total": stats.get("vram_total", 0),
|
||||||
|
"vram_free": stats.get("vram_free", 0),
|
||||||
|
"torch_version": stats.get("torch_version", ""),
|
||||||
|
"device": stats.get("device", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except req.exceptions.ConnectionError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "无法连接到 ComfyUI,请检查地址和端口"
|
||||||
|
}
|
||||||
|
except req.exceptions.Timeout:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "连接超时,请检查 ComfyUI 是否正常运行"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"错误: {str(e)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-cloud-connection")
|
||||||
|
def test_cloud_connection(request: dict):
|
||||||
|
"""测试云端 API 连接"""
|
||||||
|
import openai
|
||||||
|
|
||||||
|
provider = request.get("provider", "dall-e")
|
||||||
|
api_key = request.get("apiKey", "")
|
||||||
|
model = request.get("model", "dall-e-3")
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "API Key 不能为空"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if provider == "dall-e":
|
||||||
|
# 测试 DALL-E
|
||||||
|
client = openai.OpenAI(api_key=api_key)
|
||||||
|
|
||||||
|
# 尝试获取模型列表(轻量级测试)
|
||||||
|
models = client.models.list()
|
||||||
|
|
||||||
|
# 检查指定的模型是否存在
|
||||||
|
model_exists = any(m.id == model for m in models.data)
|
||||||
|
|
||||||
|
if model_exists:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"连接成功,模型 {model} 可用"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"模型 {model} 不可用"
|
||||||
|
}
|
||||||
|
|
||||||
|
elif provider == "stability":
|
||||||
|
# 测试 Stability AI
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
response = req.get(
|
||||||
|
"https://api.stability.ai/v1/engines/list",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}"
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "连接成功"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"HTTP {response.status_code}: {response.text}"
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"不支持的提供商: {provider}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"连接失败: {str(e)}"
|
||||||
|
}
|
||||||
292
backend/api/routes/charactersRoute.py
Normal file
292
backend/api/routes/charactersRoute.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
角色卡 API 路由
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
from typing import List
|
||||||
|
from pathlib import Path
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.services.character_service import CharacterService
|
||||||
|
except ImportError:
|
||||||
|
from services.character_service import CharacterService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||||
|
character_service = CharacterService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[dict])
|
||||||
|
async def list_characters():
|
||||||
|
"""
|
||||||
|
获取所有角色卡列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
按最后聊天时间排序的角色卡列表
|
||||||
|
"""
|
||||||
|
characters = character_service.scan_all_characters()
|
||||||
|
return [c.dict() for c in characters]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=dict)
|
||||||
|
async def get_character(name: str):
|
||||||
|
"""
|
||||||
|
获取指定角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名(URL编码)
|
||||||
|
"""
|
||||||
|
character = character_service.get_character_by_name(name)
|
||||||
|
if not character:
|
||||||
|
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||||
|
|
||||||
|
return character.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=dict)
|
||||||
|
async def create_character(character_data: dict):
|
||||||
|
"""
|
||||||
|
创建新角色卡
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"name": "角色名",
|
||||||
|
"description": "描述",
|
||||||
|
"personality": "性格",
|
||||||
|
"scenario": "场景",
|
||||||
|
"first_mes": "开场白",
|
||||||
|
"categories": ["分类1", "分类2"],
|
||||||
|
"tags": ["tag1", "tag2"]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
character = character_service.create_character(character_data)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"character": character.dict()
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{name}", response_model=dict)
|
||||||
|
async def update_character(name: str, updates: dict):
|
||||||
|
"""
|
||||||
|
更新角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
updates: 要更新的字段
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
character = character_service.update_character(name, updates)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"character": character.dict()
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{name}")
|
||||||
|
async def delete_character(name: str):
|
||||||
|
"""
|
||||||
|
删除角色卡及其所有聊天记录
|
||||||
|
"""
|
||||||
|
success = character_service.delete_character(name)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||||
|
|
||||||
|
return {"success": True, "message": f"角色 '{name}' 已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}/avatar")
|
||||||
|
async def get_avatar(name: str):
|
||||||
|
"""
|
||||||
|
获取角色头像
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG 图片文件或 404
|
||||||
|
"""
|
||||||
|
char_folder = character_service.characters_dir / name
|
||||||
|
avatar_file = char_folder / "avatar.png"
|
||||||
|
|
||||||
|
if not avatar_file.exists():
|
||||||
|
# 返回默认头像
|
||||||
|
default_avatar = Path("data/images/avatars/fallback.png")
|
||||||
|
if default_avatar.exists():
|
||||||
|
return FileResponse(default_avatar, media_type="image/png")
|
||||||
|
raise HTTPException(status_code=404, detail="头像不存在")
|
||||||
|
|
||||||
|
return FileResponse(avatar_file, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/avatar")
|
||||||
|
async def upload_avatar(name: str, file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
上传角色头像
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
file: PNG 图片文件
|
||||||
|
"""
|
||||||
|
# 验证文件类型
|
||||||
|
if not file.content_type.startswith('image/'):
|
||||||
|
raise HTTPException(status_code=400, detail="只支持图片文件")
|
||||||
|
|
||||||
|
# 检查角色是否存在
|
||||||
|
character = character_service.get_character_by_name(name)
|
||||||
|
if not character:
|
||||||
|
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||||
|
|
||||||
|
# 保存图片
|
||||||
|
image_data = await file.read()
|
||||||
|
avatar_path = character_service.save_avatar(name, image_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"avatar_path": avatar_path
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{name}/chats")
|
||||||
|
async def list_chats(name: str):
|
||||||
|
"""
|
||||||
|
获取角色的所有聊天列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
聊天文件列表(包含最后一条消息预览)
|
||||||
|
"""
|
||||||
|
char_folder = character_service.characters_dir / name
|
||||||
|
chats_dir = char_folder / "chats"
|
||||||
|
|
||||||
|
if not chats_dir.exists():
|
||||||
|
return {"chats": []}
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
chats = []
|
||||||
|
for chat_file in chats_dir.glob("*.jsonl"):
|
||||||
|
try:
|
||||||
|
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 第一行是header
|
||||||
|
header = json.loads(lines[0])
|
||||||
|
|
||||||
|
# 计算消息数量(排除header)
|
||||||
|
message_count = len(lines) - 1
|
||||||
|
|
||||||
|
# 获取最后修改时间
|
||||||
|
last_modified = datetime.fromtimestamp(
|
||||||
|
chat_file.stat().st_mtime
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
# 获取最后一条消息预览
|
||||||
|
last_message = ""
|
||||||
|
if message_count > 0:
|
||||||
|
try:
|
||||||
|
last_msg_data = json.loads(lines[-1])
|
||||||
|
last_message = last_msg_data.get("mes", "")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chats.append({
|
||||||
|
"chat_name": chat_file.stem,
|
||||||
|
"user_name": header.get("user_name", "User"),
|
||||||
|
"character_name": header.get("character_name", ""),
|
||||||
|
"last_modified": last_modified,
|
||||||
|
"message_count": message_count,
|
||||||
|
"last_message": last_message
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# 如果解析失败,使用基本信息
|
||||||
|
chats.append({
|
||||||
|
"chat_name": chat_file.stem,
|
||||||
|
"last_modified": datetime.fromtimestamp(chat_file.stat().st_mtime).isoformat(),
|
||||||
|
"message_count": 0,
|
||||||
|
"last_message": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按修改时间排序
|
||||||
|
chats.sort(key=lambda c: c.get('last_modified', ''), reverse=True)
|
||||||
|
|
||||||
|
return {"chats": chats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import")
|
||||||
|
async def import_character(file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
导入角色卡(支持 PNG 或 JSON)
|
||||||
|
|
||||||
|
- PNG: 自动提取嵌入数据,创建文件夹
|
||||||
|
- JSON: 创建文件夹并保存
|
||||||
|
"""
|
||||||
|
content = await file.read()
|
||||||
|
filename = file.filename
|
||||||
|
|
||||||
|
if filename.endswith('.png'):
|
||||||
|
# 导入 PNG
|
||||||
|
try:
|
||||||
|
character = character_service.import_from_png(content, filename)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"character": character.dict(),
|
||||||
|
"format": "png_embedded"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
elif filename.endswith('.json'):
|
||||||
|
# 导入 JSON
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
data = json.loads(content.decode('utf-8'))
|
||||||
|
|
||||||
|
character = character_service.create_character(data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"character": character.dict(),
|
||||||
|
"format": "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="不支持的文件格式")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}/export/png")
|
||||||
|
async def export_character_as_png(name: str):
|
||||||
|
"""
|
||||||
|
导出角色为 SillyTavern PNG 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG 文件下载
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
png_data = character_service.export_as_png(name)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(png_data),
|
||||||
|
media_type="image/png",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={name}.png"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
|
||||||
112
backend/api/routes/chatsRoute.py
Normal file
112
backend/api/routes/chatsRoute.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from pathlib import Path
|
||||||
|
try:
|
||||||
|
from backend.services.chat_service import ChatService
|
||||||
|
from backend.core.config import settings
|
||||||
|
except ImportError:
|
||||||
|
# Docker环境:直接从当前目录导入
|
||||||
|
from services.chat_service import ChatService
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||||
|
|
||||||
|
# 初始化聊天服务
|
||||||
|
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
||||||
|
chat_service = ChatService(data_path)
|
||||||
|
|
||||||
|
@router.get("", response_model=dict)
|
||||||
|
async def list_all_chats():
|
||||||
|
"""获取所有角色的所有聊天列表"""
|
||||||
|
return chat_service.list_all_chats()
|
||||||
|
|
||||||
|
@router.get("/{role_name}")
|
||||||
|
async def list_role_chats(role_name: str):
|
||||||
|
"""获取指定角色的所有聊天列表"""
|
||||||
|
try:
|
||||||
|
all_chats = chat_service.list_all_chats()
|
||||||
|
# 从所有聊天中筛选出该角色的聊天
|
||||||
|
role_chats = all_chats.get(role_name, [])
|
||||||
|
return role_chats
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{role_name}/{chat_name}")
|
||||||
|
async def get_chat(role_name: str, chat_name: str):
|
||||||
|
"""获取指定聊天的完整内容"""
|
||||||
|
try:
|
||||||
|
return chat_service.get_chat(role_name, chat_name)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_chat(role_name: str, chat_data: dict):
|
||||||
|
"""创建新聊天"""
|
||||||
|
try:
|
||||||
|
chat_name = chat_data.get("chat_name", "新聊天")
|
||||||
|
metadata = chat_data.get("metadata", {})
|
||||||
|
return chat_service.create_chat(role_name, chat_name, metadata)
|
||||||
|
except FileExistsError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/{role_name}/{chat_name}")
|
||||||
|
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||||
|
"""更新聊天元数据"""
|
||||||
|
# TODO: 实现更新聊天元数据功能
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.delete("/{role_name}/{chat_name}")
|
||||||
|
async def delete_chat(role_name: str, chat_name: str):
|
||||||
|
"""删除指定聊天"""
|
||||||
|
# TODO: 实现删除聊天功能
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.get("/{role_name}/{chat_name}/messages")
|
||||||
|
async def list_messages(role_name: str, chat_name: str):
|
||||||
|
"""获取聊天的所有消息"""
|
||||||
|
try:
|
||||||
|
chat_data = chat_service.get_chat(role_name, chat_name)
|
||||||
|
return {"messages": chat_data["messages"]}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
|
async def get_message(role_name: str, chat_name: str, floor: int):
|
||||||
|
"""获取指定楼层的消息"""
|
||||||
|
try:
|
||||||
|
chat_data = chat_service.get_chat(role_name, chat_name)
|
||||||
|
for msg in chat_data["messages"]:
|
||||||
|
if msg.get("floor") == floor:
|
||||||
|
return msg
|
||||||
|
raise HTTPException(status_code=404, detail=f"Message at floor {floor} not found")
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||||
|
"""向聊天添加新消息"""
|
||||||
|
try:
|
||||||
|
return chat_service.add_message(role_name, chat_name, message_data)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
|
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
||||||
|
"""更新指定楼层的消息"""
|
||||||
|
try:
|
||||||
|
return chat_service.update_message(role_name, chat_name, floor, update_data)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||||
|
async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||||
|
"""删除指定楼层的消息"""
|
||||||
|
try:
|
||||||
|
return chat_service.delete_message(role_name, chat_name, floor)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
60
backend/api/routes/presetsRoute.py
Normal file
60
backend/api/routes/presetsRoute.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
# TODO: 实现 PresetService 来替代旧的 AIDesignSpec 逻辑
|
||||||
|
# from services.preset_service import PresetService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/presets", tags=["presets"])
|
||||||
|
|
||||||
|
@router.get("", response_model=dict)
|
||||||
|
async def list_presets():
|
||||||
|
"""获取所有预设列表及其基本信息"""
|
||||||
|
# return await PresetService.list_all_presets()
|
||||||
|
return {"presets": []}
|
||||||
|
|
||||||
|
@router.get("/{preset_name}")
|
||||||
|
async def get_preset(preset_name: str):
|
||||||
|
"""获取指定预设的完整内容"""
|
||||||
|
# try:
|
||||||
|
# return await PresetService.get_preset(preset_name)
|
||||||
|
# except FileNotFoundError:
|
||||||
|
# raise HTTPException(status_code=404, detail="Preset not found")
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_preset(preset_name: str, preset_data: dict):
|
||||||
|
"""创建新预设"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.put("/{preset_name}")
|
||||||
|
async def update_preset(preset_name: str, update_data: dict):
|
||||||
|
"""更新预设配置"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.delete("/{preset_name}")
|
||||||
|
async def delete_preset(preset_name: str):
|
||||||
|
"""删除指定预设"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.get("/{preset_name}/components")
|
||||||
|
async def list_preset_components(preset_name: str):
|
||||||
|
"""获取预设中的所有组件"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.get("/{preset_name}/components/{component_id}")
|
||||||
|
async def get_preset_component(preset_name: str, component_id: str):
|
||||||
|
"""获取指定组件的详情"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_preset_component(preset_name: str, component_data: dict):
|
||||||
|
"""向预设添加新组件"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.put("/{preset_name}/components/{component_id}")
|
||||||
|
async def update_preset_component(preset_name: str, component_id: str, update_data: dict):
|
||||||
|
"""更新指定组件"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
|
|
||||||
|
@router.delete("/{preset_name}/components/{component_id}")
|
||||||
|
async def delete_preset_component(preset_name: str, component_id: str):
|
||||||
|
"""从预设中删除指定组件"""
|
||||||
|
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||||
253
backend/api/routes/worldbooksRoute.py
Normal file
253
backend/api/routes/worldbooksRoute.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# 标准库导入
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
# 第三方库导入
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
|
|
||||||
|
# 本地模块导入
|
||||||
|
from models.internal import WorldInfo, WorldInfoEntry
|
||||||
|
from core.config import settings
|
||||||
|
from services.worldbook_service import worldbook_service
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 创建路由器
|
||||||
|
router = APIRouter(prefix="/worldbooks", tags=["worldbooks"])
|
||||||
|
|
||||||
|
# 确保世界书目录存在 (由 config.py 中的 settings.ensure_directories() 统一处理,此处保留作为双重保险)
|
||||||
|
os.makedirs(settings.WORLDBOOKS_PATH, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_worldbooks():
|
||||||
|
"""
|
||||||
|
获取所有世界书的列表
|
||||||
|
Returns:
|
||||||
|
List[Dict[str, Any]]: 世界书列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.list_worldbooks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list worldbooks: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{name}", response_model=Dict[str, Any])
|
||||||
|
async def get_worldbook(name: str):
|
||||||
|
"""
|
||||||
|
获取指定名称的世界书
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.get_worldbook(name)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/", response_model=Dict[str, Any])
|
||||||
|
async def create_worldbook(
|
||||||
|
name: str = Form(...),
|
||||||
|
description: str = Form(""),
|
||||||
|
file: Optional[UploadFile] = File(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建新世界书(可选择导入文件)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 如果提供了文件,从 SillyTavern 格式导入
|
||||||
|
if file:
|
||||||
|
content = await file.read()
|
||||||
|
st_data = json.loads(content.decode('utf-8'))
|
||||||
|
return worldbook_service.import_from_sillytavern(name, st_data)
|
||||||
|
else:
|
||||||
|
# 创建空世界书
|
||||||
|
return worldbook_service.create_worldbook(name, description)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/{name}", response_model=Dict[str, Any])
|
||||||
|
async def update_worldbook(
|
||||||
|
name: str,
|
||||||
|
description: Optional[str] = Form(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新世界书基本信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.update_worldbook(name, description)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.delete("/{name}")
|
||||||
|
async def delete_worldbook(name: str):
|
||||||
|
"""
|
||||||
|
删除世界书
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
worldbook_service.delete_worldbook(name)
|
||||||
|
return {"message": f"Worldbook '{name}' deleted successfully"}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete 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}/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.post("/{name}/entries", response_model=Dict[str, Any])
|
||||||
|
async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
在世界书中创建新条目
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.create_entry(name, entry_data)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create entry in worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.put("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||||
|
async def update_worldbook_entry(name: str, uid: str, entry_data: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
更新世界书的指定条目
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return worldbook_service.update_entry(name, uid, entry_data)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update entry '{uid}' in worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.delete("/{name}/entries/{uid}")
|
||||||
|
async def delete_worldbook_entry(name: str, uid: str):
|
||||||
|
"""
|
||||||
|
删除世界书的指定条目
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
worldbook_service.delete_entry(name, uid)
|
||||||
|
return {"message": f"Entry '{uid}' deleted successfully"}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.post("/{name}/import", response_model=Dict[str, Any])
|
||||||
|
async def import_worldbook(name: str, file: UploadFile = File(...)):
|
||||||
|
"""
|
||||||
|
从文件导入世界书(自动检测 SillyTavern 或内部格式)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
data = json.loads(content.decode('utf-8'))
|
||||||
|
|
||||||
|
# 智能检测格式
|
||||||
|
from models.converters import WorldBookConverter
|
||||||
|
format_type = WorldBookConverter.detect_format(data)
|
||||||
|
|
||||||
|
logger.info(f"检测到世界书格式: {format_type}")
|
||||||
|
|
||||||
|
if format_type == "sillytavern":
|
||||||
|
# SillyTavern 格式,需要转换
|
||||||
|
logger.info(f"正在转换 SillyTavern 格式为内部格式")
|
||||||
|
return worldbook_service.import_from_sillytavern(name, data)
|
||||||
|
elif format_type == "internal":
|
||||||
|
# 已经是内部格式,直接保存
|
||||||
|
logger.info(f"检测到内部格式,直接保存")
|
||||||
|
return worldbook_service.import_internal_format(name, data)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="无法识别的世界书格式")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@router.get("/{name}/export")
|
||||||
|
async def export_worldbook(name: str, format: str = "internal"):
|
||||||
|
"""
|
||||||
|
导出世界书(支持 internal 和 sillytavern 两种格式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if format.lower() == "sillytavern":
|
||||||
|
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
||||||
|
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
||||||
|
st_data = worldbook_service.export_to_sillytavern(name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=st_data,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 导出为内部格式(保留所有设置)
|
||||||
|
logger.info(f"导出世界书 '{name}' 为内部格式")
|
||||||
|
internal_data = worldbook_service.get_worldbook(name)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content=internal_data,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={name}.json"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
BIN
backend/core/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
backend/core/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
backend/core/__pycache__/config.cpython-39.pyc
Normal file
BIN
backend/core/__pycache__/config.cpython-39.pyc
Normal file
Binary file not shown.
@@ -3,11 +3,12 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 1. 动态计算项目根目录
|
# 1. 动态计算项目根目录
|
||||||
# 假设 config.py 位于 backend/ 目录下
|
# 假设 config.py 位于 backend/core/ 目录下
|
||||||
# __file__ 指向本文件的绝对路径
|
# __file__ 指向本文件的绝对路径
|
||||||
# .parent 指向 backend/ 目录
|
# .parent 指向 backend/core/ 目录
|
||||||
# .parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
# .parent.parent 指向 backend/ 目录
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent # 修改这里,添加一个 .parent
|
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
# 2. 加载 .env 文件
|
# 2. 加载 .env 文件
|
||||||
# 假设 .env 文件位于项目根目录下
|
# 假设 .env 文件位于项目根目录下
|
||||||
@@ -15,33 +16,76 @@ load_dotenv(PROJECT_ROOT / ".env")
|
|||||||
|
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
# --- 主模型配置 ---
|
# --- 主模型配置 ---
|
||||||
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
|
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
|
||||||
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
|
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_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_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
|
||||||
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
|
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
|
||||||
|
|
||||||
# --- 路径配置 (核心修改) ---
|
# --- 路径配置 (核心修改) ---
|
||||||
|
|
||||||
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
|
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
|
||||||
BASE_PATH = PROJECT_ROOT
|
BASE_PATH = PROJECT_ROOT
|
||||||
|
|
||||||
# 数据目录:固定为根目录下的 data 文件夹
|
# 数据目录:固定为根目录下的 data 文件夹
|
||||||
# 即使 .env 里写了 DATA_PATH=/data,这里也会强制指向项目根目录下的 data
|
DATA_PATH = BASE_PATH / "data"
|
||||||
DATA_PATH = BASE_PATH / "data"
|
|
||||||
|
# --- 核心数据文件路径 ---
|
||||||
|
STATE_FILE = DATA_PATH / "state.json"
|
||||||
|
SCHEMA_FILE = DATA_PATH / "schema.json"
|
||||||
|
PRESETS_FILE = DATA_PATH / "presets.json"
|
||||||
|
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
||||||
|
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
||||||
|
|
||||||
|
# --- 业务数据目录 ---
|
||||||
|
|
||||||
|
# 世界书目录
|
||||||
|
WORLDBOOKS_PATH = DATA_PATH / "worldbooks"
|
||||||
|
|
||||||
|
# 预设目录
|
||||||
|
PRESET_PATH = DATA_PATH / "preset"
|
||||||
|
|
||||||
|
# 聊天记录目录
|
||||||
|
CHAT_PATH = DATA_PATH / "chat"
|
||||||
|
|
||||||
|
# 临时文件目录
|
||||||
|
TEMP_PATH = DATA_PATH / "temp"
|
||||||
|
|
||||||
|
# ComfyUI 工作流目录
|
||||||
|
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
||||||
|
|
||||||
|
# 角色卡目录
|
||||||
|
CHARACTERS_PATH = DATA_PATH / "characters"
|
||||||
|
|
||||||
|
# 图片资源目录
|
||||||
|
IMAGES_PATH = DATA_PATH / "images"
|
||||||
|
|
||||||
|
def ensure_directories(self):
|
||||||
|
"""确保所有配置的目录存在,如果不存在则创建"""
|
||||||
|
directories = [
|
||||||
|
self.DATA_PATH,
|
||||||
|
self.WORLDBOOKS_PATH,
|
||||||
|
self.PRESET_PATH,
|
||||||
|
self.CHAT_PATH,
|
||||||
|
self.TEMP_PATH,
|
||||||
|
self.COMFYUI_WORKFLOWS_PATH,
|
||||||
|
self.CHARACTERS_PATH,
|
||||||
|
self.IMAGES_PATH,
|
||||||
|
]
|
||||||
|
for directory in directories:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# 其他文件路径:基于 DATA_PATH 拼接
|
|
||||||
STATE_FILE = DATA_PATH / "state.json"
|
|
||||||
SCHEMA_FILE = DATA_PATH / "schema.json"
|
|
||||||
PRESETS_FILE = DATA_PATH / "presets.json"
|
|
||||||
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
|
||||||
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
# 初始化时自动创建必要的目录
|
||||||
# 实例化配置对象
|
settings.ensure_directories()
|
||||||
settings = Settings()
|
|
||||||
print(settings.BASE_PATH)
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
settings = Settings()
|
||||||
|
print(f"项目根目录: {settings.BASE_PATH}")
|
||||||
|
print(f"数据目录: {settings.DATA_PATH}")
|
||||||
|
print(f"世界书目录: {settings.WORLDBOOKS_PATH}")
|
||||||
|
print(f"预设目录: {settings.PRESETS_PATH}")
|
||||||
|
print(f"聊天目录: {settings.CHAT_PATH}")
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
|
|
||||||
# 1. 定义请求体模型
|
|
||||||
class ChatRequest(BaseModel):
|
|
||||||
# --- 基础信息 ---
|
|
||||||
mes: str = Field(..., description="用户输入的消息内容")
|
|
||||||
is_user: bool = Field(..., description="标识发送者是否为用户(True为用户,False为AI)")
|
|
||||||
floor_number: int = Field(..., description="当前对话的楼层号,用于判断是否为重试(Regenerate)请求")
|
|
||||||
|
|
||||||
# --- 身份与会话 ---
|
|
||||||
name: str = Field("default", description="发送者的显示名称,默认为'default'")
|
|
||||||
role_name: Optional[str] = Field(None, description="当前绑定的角色名称")
|
|
||||||
chat_name: Optional[str] = Field(None, description="当前会话的标识名称")
|
|
||||||
preset: Optional[str] = Field(None, description="预设的提示词或系统指令")
|
|
||||||
|
|
||||||
# --- 功能开关 ---
|
|
||||||
stream: bool = Field(False, description="是否开启流式输出")
|
|
||||||
img_switch: bool = Field(False, description="是否开启图片生成功能")
|
|
||||||
table_switch: bool = Field(False, description="是否开启表格生成功能")
|
|
||||||
|
|
||||||
# 其他可能需要的参数,比如历史记录,可以在这里加
|
|
||||||
# history: Optional[List[Dict]] = None
|
|
||||||
@@ -1,12 +1,51 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 确保所有模块的日志都能被捕获
|
||||||
|
for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
|
||||||
|
logging_logger = logging.getLogger(logger_name)
|
||||||
|
logging_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
# backend/app/main.py
|
# backend/app/main.py
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from .api.routes import router
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
try:
|
||||||
|
from backend.api.route import router
|
||||||
|
except ImportError:
|
||||||
|
from api.route import router
|
||||||
app = FastAPI(title="LLM Workflow Engine")
|
app = FastAPI(title="LLM Workflow Engine")
|
||||||
|
|
||||||
|
# 配置CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 开发环境允许所有来源,生产环境应该指定具体域名
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
# 注册路由
|
# 注册路由
|
||||||
app.include_router(router, prefix="/api")
|
app.include_router(router, prefix="/api")
|
||||||
|
|
||||||
|
# 添加健康检查端点
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
# 添加根路径
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "LLM Workflow Engine", "status": "running"}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|||||||
231
backend/models/README.md
Normal file
231
backend/models/README.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Backend Models 数据模型说明
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
models/
|
||||||
|
├── __init__.py # 包初始化,导出所有模型
|
||||||
|
├── sillytavern.py # SillyTavern 兼容模型 (仅用于导入/导出)
|
||||||
|
├── internal.py # 内部业务模型 (项目核心使用)
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模型分类
|
||||||
|
|
||||||
|
### 1. SillyTavern 兼容模型 (`sillytavern.py`)
|
||||||
|
|
||||||
|
**用途**: 仅用于与 SillyTavern 格式的数据进行导入/导出兼容
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- 严格遵循 SillyTavern 官方规范
|
||||||
|
- 不参与内部业务逻辑
|
||||||
|
- 所有字段名、结构与 SillyTavern 保持一致
|
||||||
|
- 前缀 `ST` 表示 SillyTavern
|
||||||
|
|
||||||
|
**主要模型**:
|
||||||
|
- `STWorldInfo` - SillyTavern 世界书
|
||||||
|
- `STCharacterCard` - SillyTavern 角色卡
|
||||||
|
- `STChatHeader` / `STChatMessage` - SillyTavern 聊天记录
|
||||||
|
- `STGenerationPreset` - SillyTavern 采样预设
|
||||||
|
- `STPromptPreset` - SillyTavern 提示词预设
|
||||||
|
|
||||||
|
**使用场景**:
|
||||||
|
```python
|
||||||
|
# 从 SillyTavern 导入时
|
||||||
|
st_data = json.load(file)
|
||||||
|
st_character = STCharacterCard(**st_data)
|
||||||
|
|
||||||
|
# 转换为内部模型
|
||||||
|
internal_character = converter.st_to_internal(st_character)
|
||||||
|
|
||||||
|
# 导出到 SillyTavern 时
|
||||||
|
st_data = converter.internal_to_st(internal_character)
|
||||||
|
json.dump(st_data.dict(), file)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 内部业务模型 (`internal.py`)
|
||||||
|
|
||||||
|
**用途**: 项目内部真正使用的数据结构,所有业务逻辑都基于这些模型
|
||||||
|
|
||||||
|
**特点**:
|
||||||
|
- 继承并扩展了 SillyTavern 的功能
|
||||||
|
- 添加了项目特色功能 (如 LOGIC 激活、RAG 配置、outputSchema 等)
|
||||||
|
- 所有 API 响应、数据存储、工作流交换都使用这些模型
|
||||||
|
- 无前缀,直接使用语义化名称
|
||||||
|
|
||||||
|
**主要模型**:
|
||||||
|
|
||||||
|
#### 世界书相关
|
||||||
|
- `ActivationType` - 激活方式枚举 (PERMANENT/KEYWORD/RAG/LOGIC)
|
||||||
|
- `LogicExpression` - 逻辑表达式
|
||||||
|
- `RAGConfig` - RAG 检索配置
|
||||||
|
- `WorldInfoEntry` - 世界书条目
|
||||||
|
- `WorldInfo` - 世界书
|
||||||
|
|
||||||
|
#### 角色卡相关
|
||||||
|
- `OutputSchemaField` - 结构化输出 schema
|
||||||
|
- `CharacterCard` - 角色卡
|
||||||
|
|
||||||
|
#### 聊天记录相关
|
||||||
|
- `ChatHeader` - 聊天头
|
||||||
|
- `ChatMessage` - 聊天消息
|
||||||
|
- `ChatLog` - 完整聊天记录
|
||||||
|
|
||||||
|
#### 预设相关
|
||||||
|
- `GenerationPreset` - 采样参数预设
|
||||||
|
- `PromptRole` - Prompt 角色枚举
|
||||||
|
- `PromptEntry` - Prompt 条目
|
||||||
|
- `PromptPresetView` - Prompt 预设视图
|
||||||
|
|
||||||
|
#### RAG 配置
|
||||||
|
- `RAGSearchConfig` - RAG 搜索配置
|
||||||
|
- `CharacterRAGConfig` - 角色卡 RAG 配置
|
||||||
|
- `ChatRAGConfig` - 聊天 RAG 配置
|
||||||
|
|
||||||
|
**使用场景**:
|
||||||
|
```python
|
||||||
|
# 业务逻辑中直接使用
|
||||||
|
from models import CharacterCard, WorldInfo
|
||||||
|
|
||||||
|
character = CharacterCard(
|
||||||
|
id="uuid-123",
|
||||||
|
name="Alice",
|
||||||
|
description="...",
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
# API 响应
|
||||||
|
@app.get("/characters/{id}")
|
||||||
|
async def get_character(id: str):
|
||||||
|
character = service.get_character(id)
|
||||||
|
return character # 返回 internal 模型
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据转换流程
|
||||||
|
|
||||||
|
```
|
||||||
|
SillyTavern 文件
|
||||||
|
↓ (导入)
|
||||||
|
STCharacterCard (sillytavern.py)
|
||||||
|
↓ (转换器)
|
||||||
|
CharacterCard (internal.py)
|
||||||
|
↓ (业务处理)
|
||||||
|
CharacterCard (internal.py)
|
||||||
|
↓ (转换器)
|
||||||
|
STCharacterCard (sillytavern.py)
|
||||||
|
↓ (导出)
|
||||||
|
SillyTavern 文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
### ✅ 正确做法
|
||||||
|
|
||||||
|
1. **业务逻辑使用 internal 模型**
|
||||||
|
```python
|
||||||
|
from models import CharacterCard
|
||||||
|
|
||||||
|
def create_character(data: dict) -> CharacterCard:
|
||||||
|
return CharacterCard(**data)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **导入时使用转换器**
|
||||||
|
```python
|
||||||
|
from models import STCharacterCard, CharacterCard
|
||||||
|
from models.converters import CharacterConverter
|
||||||
|
|
||||||
|
def import_character(file_path: str) -> CharacterCard:
|
||||||
|
st_data = load_json(file_path)
|
||||||
|
st_char = STCharacterCard(**st_data)
|
||||||
|
return CharacterConverter.st_to_internal(st_char)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **API 响应使用 internal 模型**
|
||||||
|
```python
|
||||||
|
@app.get("/characters")
|
||||||
|
async def list_characters() -> List[CharacterCard]:
|
||||||
|
return service.list_characters()
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 错误做法
|
||||||
|
|
||||||
|
1. **不要在业务逻辑中直接使用 ST 模型**
|
||||||
|
```python
|
||||||
|
# 错误!
|
||||||
|
from models import STCharacterCard
|
||||||
|
|
||||||
|
def process_character(char: STCharacterCard):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **不要混合使用两种模型**
|
||||||
|
```python
|
||||||
|
# 错误!
|
||||||
|
character = CharacterCard(...)
|
||||||
|
character.name = st_character.data.name # 不要混用
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **不要在 API 中暴露 ST 模型**
|
||||||
|
```python
|
||||||
|
# 错误!
|
||||||
|
@app.get("/characters")
|
||||||
|
async def list_characters() -> List[STCharacterCard]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加新模型
|
||||||
|
|
||||||
|
当需要添加新的数据类型时:
|
||||||
|
|
||||||
|
1. **判断用途**:
|
||||||
|
- 如果是为了 SillyTavern 兼容 → 添加到 `sillytavern.py`
|
||||||
|
- 如果是项目内部使用 → 添加到 `internal.py`
|
||||||
|
|
||||||
|
2. **遵循命名规范**:
|
||||||
|
- SillyTavern 模型: 前缀 `ST`
|
||||||
|
- 内部模型: 无前缀,使用清晰的语义化名称
|
||||||
|
|
||||||
|
3. **添加详细注释**:
|
||||||
|
```python
|
||||||
|
class MyModel(BaseModel):
|
||||||
|
"""
|
||||||
|
模型用途说明
|
||||||
|
|
||||||
|
详细描述该模型的作用、使用场景等
|
||||||
|
"""
|
||||||
|
field1: str = Field(..., description="字段说明")
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **在 `__init__.py` 中导出**:
|
||||||
|
```python
|
||||||
|
from .internal import MyModel
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
...,
|
||||||
|
'MyModel',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 转换器 (待实现)
|
||||||
|
|
||||||
|
`models/converters.py` 将提供双向转换功能:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CharacterConverter:
|
||||||
|
@staticmethod
|
||||||
|
def st_to_internal(st_char: STCharacterCard) -> CharacterCard:
|
||||||
|
"""SillyTavern → Internal"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def internal_to_st(int_char: CharacterCard) -> STCharacterCard:
|
||||||
|
"""Internal → SillyTavern"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
- **sillytavern.py** = 外部兼容层 (Import/Export Only)
|
||||||
|
- **internal.py** = 内部业务层 (Core Business Logic)
|
||||||
|
- **永远在业务逻辑中使用 internal 模型**
|
||||||
|
- **通过转换器进行格式转换**
|
||||||
61
backend/models/__init__.py
Normal file
61
backend/models/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
数据模型包
|
||||||
|
|
||||||
|
导出项目内部真正使用的数据结构 (Internal Models)。
|
||||||
|
SillyTavern 兼容模型将在需要导入/导出时单独引用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 内部业务模型 (项目核心使用)
|
||||||
|
from .internal import (
|
||||||
|
# 世界书
|
||||||
|
ActivationType,
|
||||||
|
LogicOperator,
|
||||||
|
LogicExpression,
|
||||||
|
RAGConfig,
|
||||||
|
WorldInfoEntry,
|
||||||
|
WorldInfo,
|
||||||
|
|
||||||
|
# 角色卡
|
||||||
|
OutputSchemaField,
|
||||||
|
CharacterCard,
|
||||||
|
|
||||||
|
# 聊天记录
|
||||||
|
ChatHeader,
|
||||||
|
ChatMessage,
|
||||||
|
ChatLog,
|
||||||
|
|
||||||
|
# 预设
|
||||||
|
GenerationPreset,
|
||||||
|
|
||||||
|
# 提示词预设
|
||||||
|
PromptRole,
|
||||||
|
PromptEntry,
|
||||||
|
PromptPresetView,
|
||||||
|
|
||||||
|
# RAG 配置
|
||||||
|
RAGSearchConfig,
|
||||||
|
CharacterRAGConfig,
|
||||||
|
ChatRAGConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# 内部模型
|
||||||
|
'ActivationType',
|
||||||
|
'LogicOperator',
|
||||||
|
'LogicExpression',
|
||||||
|
'RAGConfig',
|
||||||
|
'WorldInfoEntry',
|
||||||
|
'WorldInfo',
|
||||||
|
'OutputSchemaField',
|
||||||
|
'CharacterCard',
|
||||||
|
'ChatHeader',
|
||||||
|
'ChatMessage',
|
||||||
|
'ChatLog',
|
||||||
|
'GenerationPreset',
|
||||||
|
'PromptRole',
|
||||||
|
'PromptEntry',
|
||||||
|
'PromptPresetView',
|
||||||
|
'RAGSearchConfig',
|
||||||
|
'CharacterRAGConfig',
|
||||||
|
'ChatRAGConfig',
|
||||||
|
]
|
||||||
379
backend/models/converters.py
Normal file
379
backend/models/converters.py
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
数据模型转换器
|
||||||
|
|
||||||
|
提供 SillyTavern 格式与内部格式之间的双向转换功能。
|
||||||
|
所有导入/导出操作都应该通过转换器进行,确保数据格式的一致性。
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from models.internal import (
|
||||||
|
WorldInfo,
|
||||||
|
WorldInfoEntry,
|
||||||
|
ActivationType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorldBookConverter:
|
||||||
|
"""世界书数据转换器
|
||||||
|
|
||||||
|
负责 SillyTavern 格式和项目内部格式之间的转换。
|
||||||
|
|
||||||
|
SillyTavern 格式特点:
|
||||||
|
- entries 是 dict (key 为 uid)
|
||||||
|
- 使用 constant 字段表示常驻激活
|
||||||
|
- position 是字符串 (如 "after_char")
|
||||||
|
|
||||||
|
项目内部格式特点:
|
||||||
|
- entries 是 list
|
||||||
|
- 使用 activationType 枚举
|
||||||
|
- position 是数字 (0-5)
|
||||||
|
- 包含 trigger_config 结构(前端需要)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_format(data: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
智能检测世界书数据格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 世界书数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
'sillytavern' | 'internal' | 'unknown'
|
||||||
|
"""
|
||||||
|
# 检查 entries 类型
|
||||||
|
entries = data.get("entries")
|
||||||
|
if not entries:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# SillyTavern 特征: entries 是 dict
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
return "sillytavern"
|
||||||
|
|
||||||
|
# 内部格式特征: entries 是 list
|
||||||
|
if isinstance(entries, list):
|
||||||
|
# 进一步检查是否有 trigger_config
|
||||||
|
if len(entries) > 0 and isinstance(entries[0], dict):
|
||||||
|
first_entry = entries[0]
|
||||||
|
if "trigger_config" in first_entry:
|
||||||
|
return "internal"
|
||||||
|
# 也可能是简化的内部格式
|
||||||
|
if "activationType" in first_entry or "position" in first_entry:
|
||||||
|
return "internal"
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# 位置映射: SillyTavern 字符串 -> 内部数字
|
||||||
|
POSITION_MAP_ST_TO_INTERNAL = {
|
||||||
|
"after_char": 0,
|
||||||
|
"before_char": 1,
|
||||||
|
"before_example": 2,
|
||||||
|
"after_example": 3,
|
||||||
|
"author_note": 4,
|
||||||
|
"system_prompt": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 位置映射: 内部数字 -> SillyTavern 字符串
|
||||||
|
POSITION_MAP_INTERNAL_TO_ST = {
|
||||||
|
0: "after_char",
|
||||||
|
1: "before_char",
|
||||||
|
2: "before_example",
|
||||||
|
3: "after_example",
|
||||||
|
4: "author_note",
|
||||||
|
5: "system_prompt",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def st_to_internal(st_data: Dict[str, Any], name: str = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将 SillyTavern 格式的世界书转换为内部格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
st_data: SillyTavern 格式的世界书数据
|
||||||
|
name: 世界书名称(可选,优先使用 st_data 中的 name)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
内部格式的世界书字典(包含 trigger_config)
|
||||||
|
"""
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# 转换条目
|
||||||
|
entries = []
|
||||||
|
st_entries = st_data.get("entries", {})
|
||||||
|
|
||||||
|
# SillyTavern 的 entries 可能是 dict 或 list
|
||||||
|
if isinstance(st_entries, dict):
|
||||||
|
entries_list = list(st_entries.values())
|
||||||
|
elif isinstance(st_entries, list):
|
||||||
|
entries_list = st_entries
|
||||||
|
else:
|
||||||
|
entries_list = []
|
||||||
|
|
||||||
|
for st_entry in entries_list:
|
||||||
|
if not isinstance(st_entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 判断激活类型
|
||||||
|
is_constant = st_entry.get("constant", False)
|
||||||
|
activation_type = ActivationType.PERMANENT if is_constant else ActivationType.KEYWORD
|
||||||
|
|
||||||
|
# 转换位置
|
||||||
|
st_position = st_entry.get("position", "after_char")
|
||||||
|
internal_position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(st_position, 0)
|
||||||
|
|
||||||
|
# 构建 trigger_config (前端期望的格式)
|
||||||
|
trigger_config = WorldBookConverter._build_trigger_config(
|
||||||
|
is_constant=is_constant,
|
||||||
|
key=st_entry.get("key", []),
|
||||||
|
keysecondary=st_entry.get("keysecondary", []),
|
||||||
|
selective=st_entry.get("selective", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建内部格式的条目
|
||||||
|
entry_dict = {
|
||||||
|
"uid": st_entry.get("uid", str(uuid.uuid4())),
|
||||||
|
"key": st_entry.get("key", []),
|
||||||
|
"keysecondary": st_entry.get("keysecondary", []),
|
||||||
|
"content": st_entry.get("content", ""),
|
||||||
|
"comment": st_entry.get("comment", ""),
|
||||||
|
"activationType": activation_type.value,
|
||||||
|
"trigger_config": trigger_config,
|
||||||
|
"order": st_entry.get("order", 100),
|
||||||
|
"position": internal_position,
|
||||||
|
"depth": st_entry.get("depth", 4),
|
||||||
|
"role": st_entry.get("role", 0),
|
||||||
|
"probability": st_entry.get("probability", 100),
|
||||||
|
"group": st_entry.get("group", []),
|
||||||
|
"disable": st_entry.get("disable", False),
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.append(entry_dict)
|
||||||
|
|
||||||
|
# 创建内部格式的世界书
|
||||||
|
worldbook_data = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": name or st_data.get("name", "Unnamed"),
|
||||||
|
"description": st_data.get("description", ""),
|
||||||
|
"entries": entries,
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return worldbook_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def internal_to_st(worldbook_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将内部格式的世界书转换为 SillyTavern 格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
worldbook_data: 内部格式的世界书字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SillyTavern 格式的世界书数据
|
||||||
|
"""
|
||||||
|
# 转换条目
|
||||||
|
st_entries = {}
|
||||||
|
|
||||||
|
for entry_data in worldbook_data.get("entries", []):
|
||||||
|
if not isinstance(entry_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
uid = entry_data.get("uid", str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 从 trigger_config 或 activationType 判断是否常驻
|
||||||
|
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
||||||
|
|
||||||
|
# 提取关键词
|
||||||
|
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
||||||
|
|
||||||
|
# 转换位置
|
||||||
|
internal_position = entry_data.get("position", 0)
|
||||||
|
st_position = WorldBookConverter.POSITION_MAP_INTERNAL_TO_ST.get(internal_position, "after_char")
|
||||||
|
|
||||||
|
# 创建 SillyTavern 格式的条目
|
||||||
|
st_entry = {
|
||||||
|
"uid": uid,
|
||||||
|
"key": key,
|
||||||
|
"keysecondary": keysecondary,
|
||||||
|
"content": entry_data.get("content", ""),
|
||||||
|
"comment": entry_data.get("comment", ""),
|
||||||
|
"constant": is_constant,
|
||||||
|
"selective": not is_constant,
|
||||||
|
"order": entry_data.get("order", 100),
|
||||||
|
"position": st_position,
|
||||||
|
"depth": entry_data.get("depth", 4),
|
||||||
|
"probability": entry_data.get("probability", 100),
|
||||||
|
"group": entry_data.get("group", []),
|
||||||
|
"disable": entry_data.get("disable", False)
|
||||||
|
}
|
||||||
|
|
||||||
|
st_entries[uid] = st_entry
|
||||||
|
|
||||||
|
# 创建 SillyTavern 格式的世界书
|
||||||
|
st_data = {
|
||||||
|
"name": worldbook_data.get("name", ""),
|
||||||
|
"description": worldbook_data.get("description", ""),
|
||||||
|
"entries": st_entries
|
||||||
|
}
|
||||||
|
|
||||||
|
return st_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize_entry(entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
规范化条目数据,确保包含所有必需字段和 trigger_config
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_data: 条目数据(可能来自不同来源)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
规范化后的条目数据
|
||||||
|
"""
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# 如果已经有 trigger_config,直接返回
|
||||||
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||||
|
return entry_data
|
||||||
|
|
||||||
|
# 否则从其他字段构建 trigger_config
|
||||||
|
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
||||||
|
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
||||||
|
|
||||||
|
trigger_config = WorldBookConverter._build_trigger_config(
|
||||||
|
is_constant=is_constant,
|
||||||
|
key=key,
|
||||||
|
keysecondary=keysecondary,
|
||||||
|
selective=entry_data.get("selective", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加缺失的字段
|
||||||
|
normalized = {
|
||||||
|
"uid": entry_data.get("uid", str(uuid.uuid4())),
|
||||||
|
"key": key,
|
||||||
|
"keysecondary": keysecondary,
|
||||||
|
"content": entry_data.get("content", ""),
|
||||||
|
"comment": entry_data.get("comment", ""),
|
||||||
|
"activationType": entry_data.get("activationType",
|
||||||
|
ActivationType.PERMANENT.value if is_constant
|
||||||
|
else ActivationType.KEYWORD.value),
|
||||||
|
"trigger_config": trigger_config,
|
||||||
|
"order": entry_data.get("order", 100),
|
||||||
|
"position": entry_data.get("position", 0),
|
||||||
|
"depth": entry_data.get("depth", 4),
|
||||||
|
"role": entry_data.get("role", 0),
|
||||||
|
"probability": entry_data.get("probability", 100),
|
||||||
|
"group": entry_data.get("group", []),
|
||||||
|
"disable": entry_data.get("disable", False),
|
||||||
|
"createdAt": entry_data.get("createdAt", now),
|
||||||
|
"updatedAt": entry_data.get("updatedAt", now)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_trigger_config(
|
||||||
|
is_constant: bool,
|
||||||
|
key: List[str],
|
||||||
|
keysecondary: List[str],
|
||||||
|
selective: bool = True
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
构建 trigger_config 结构
|
||||||
|
|
||||||
|
Args:
|
||||||
|
is_constant: 是否常驻激活
|
||||||
|
key: 主关键词列表
|
||||||
|
keysecondary: 次要关键词列表
|
||||||
|
selective: 是否选择性匹配
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
trigger_config 字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"triggers": {
|
||||||
|
"constant": [is_constant, None],
|
||||||
|
"keyword": [
|
||||||
|
not is_constant,
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"keysecondary": keysecondary,
|
||||||
|
"selective": selective,
|
||||||
|
"selectiveLogic": 0,
|
||||||
|
"matchWholeWords": False,
|
||||||
|
"caseSensitive": False
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rag": [False, {
|
||||||
|
"threshold": 0.75,
|
||||||
|
"top_k": 5,
|
||||||
|
"query_template": None
|
||||||
|
}],
|
||||||
|
"condition": [False, {
|
||||||
|
"variable_a": "",
|
||||||
|
"operator": "=",
|
||||||
|
"variable_b": ""
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_constant_entry(entry_data: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
判断条目是否为常驻激活
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_data: 条目数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否常驻激活
|
||||||
|
"""
|
||||||
|
# 优先从 trigger_config 判断
|
||||||
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||||
|
try:
|
||||||
|
return entry_data["trigger_config"]["triggers"]["constant"][0]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 其次从 activationType 判断
|
||||||
|
if "activationType" in entry_data:
|
||||||
|
return entry_data["activationType"] == ActivationType.PERMANENT.value
|
||||||
|
|
||||||
|
# 最后从 constant 字段判断
|
||||||
|
if "constant" in entry_data:
|
||||||
|
return entry_data["constant"]
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_keywords(entry_data: Dict[str, Any]) -> tuple:
|
||||||
|
"""
|
||||||
|
从条目数据中提取关键词
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_data: 条目数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(key, keysecondary) 元组
|
||||||
|
"""
|
||||||
|
# 优先从 trigger_config 提取
|
||||||
|
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||||
|
try:
|
||||||
|
keyword_config = entry_data["trigger_config"]["triggers"]["keyword"][1]
|
||||||
|
if keyword_config:
|
||||||
|
key = keyword_config.get("key", [])
|
||||||
|
keysecondary = keyword_config.get("keysecondary", [])
|
||||||
|
return key, keysecondary
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 否则从顶层字段提取
|
||||||
|
key = entry_data.get("key", [])
|
||||||
|
keysecondary = entry_data.get("keysecondary", [])
|
||||||
|
|
||||||
|
return key, keysecondary
|
||||||
301
backend/models/internal.py
Normal file
301
backend/models/internal.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""
|
||||||
|
项目内部数据结构定义
|
||||||
|
|
||||||
|
这是本项目真正使用的核心数据模型,所有业务逻辑都基于这些类型。
|
||||||
|
与 sillytavern.py 不同,这里的模型不参与导入导出兼容,而是专注于:
|
||||||
|
- 内部业务逻辑处理
|
||||||
|
- API 响应数据结构
|
||||||
|
- 数据存储格式
|
||||||
|
- 工作流引擎数据交换
|
||||||
|
|
||||||
|
所有从 SillyTavern 导入的数据都会转换为这些内部模型进行处理,
|
||||||
|
导出时再从内部模型转换回 SillyTavern 格式。
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 世界书 (World Info) ====================
|
||||||
|
|
||||||
|
class ActivationType(str, Enum):
|
||||||
|
"""
|
||||||
|
自定义激活方式类型(4种枚举)
|
||||||
|
|
||||||
|
这是项目的核心创新点之一,相比 SillyTavern 的简单 constant/selective 标志,
|
||||||
|
我们提供了更灵活的激活机制。
|
||||||
|
"""
|
||||||
|
PERMANENT = 'permanent' # 永久激活 - 始终包含在上下文中
|
||||||
|
KEYWORD = 'keyword' # 关键词触发 - 匹配关键词时激活
|
||||||
|
RAG = 'rag' # RAG 检索激活 - 基于向量相似度检索
|
||||||
|
LOGIC = 'logic' # 逻辑表达式激活 - 基于变量条件判断
|
||||||
|
|
||||||
|
|
||||||
|
class LogicOperator(str, Enum):
|
||||||
|
"""逻辑运算符(用于 LOGIC 激活类型)"""
|
||||||
|
EQUALS = 'equals' # 等于
|
||||||
|
NOT_EQUALS = 'not_equals' # 不等于
|
||||||
|
CONTAINS = 'contains' # 包含
|
||||||
|
NOT_CONTAINS = 'not_contains' # 不包含
|
||||||
|
GREATER = 'greater' # 大于
|
||||||
|
LESS = 'less' # 小于
|
||||||
|
|
||||||
|
|
||||||
|
class LogicExpression(BaseModel):
|
||||||
|
"""
|
||||||
|
逻辑表达式结构(用于 LOGIC 激活类型)
|
||||||
|
|
||||||
|
示例: variable1="mood", operator="equals", variable2="happy"
|
||||||
|
表示当 mood 变量等于 happy 时激活该条目
|
||||||
|
"""
|
||||||
|
variable1: str = Field(..., description="第一个变量名")
|
||||||
|
operator: LogicOperator = Field(..., description="比较运算符")
|
||||||
|
variable2: str = Field(..., description="第二个变量名或值")
|
||||||
|
|
||||||
|
|
||||||
|
class RAGConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
RAG 配置(用于 RAG 激活类型)
|
||||||
|
|
||||||
|
控制如何从向量数据库中检索相关内容
|
||||||
|
"""
|
||||||
|
libraryId: str = Field(..., description="绑定的 RAG 库 ID")
|
||||||
|
threshold: Optional[float] = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
|
||||||
|
maxEntries: Optional[int] = Field(5, gt=0, description="最大返回条目数")
|
||||||
|
|
||||||
|
|
||||||
|
class WorldInfoEntry(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部世界书条目结构
|
||||||
|
|
||||||
|
这是世界书的核心单元,每个条目代表一段可以被动态注入到对话上下文中的知识。
|
||||||
|
相比 SillyTavern,我们添加了 activationType、logicExpression、ragConfig 等高级功能。
|
||||||
|
"""
|
||||||
|
uid: str = Field(..., description="条目唯一标识符 (UUID)")
|
||||||
|
key: Optional[List[str]] = Field(None, description="主关键词列表 (用于 KEYWORD 激活)")
|
||||||
|
keysecondary: Optional[List[str]] = Field(None, description="次要关键词列表 (可选过滤)")
|
||||||
|
content: str = Field(..., description="条目内容 - 激活时注入的文本")
|
||||||
|
activationType: ActivationType = Field(..., description="激活方式")
|
||||||
|
logicExpression: Optional[LogicExpression] = Field(None, description="逻辑表达式 (LOGIC 类型使用)")
|
||||||
|
ragConfig: Optional[RAGConfig] = Field(None, description="RAG 配置 (RAG 类型使用)")
|
||||||
|
order: int = Field(0, description="插入顺序 - 数值越大越靠近末尾")
|
||||||
|
position: Optional[str] = Field('after_char', description="插入位置")
|
||||||
|
depth: Optional[int] = Field(None, description="插入深度 (当 position='at_depth' 时使用)")
|
||||||
|
probability: Optional[float] = Field(100, ge=0, le=100, description="激活概率 (0-100)")
|
||||||
|
group: Optional[List[str]] = Field(None, description="所属组标签")
|
||||||
|
disable: bool = Field(False, description="是否禁用")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
|
||||||
|
|
||||||
|
class WorldInfo(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部世界书结构
|
||||||
|
|
||||||
|
世界书是角色知识的集合,可以绑定到角色卡上,在对话中动态提供背景信息。
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="世界书唯一标识符 (UUID)")
|
||||||
|
name: str = Field(..., description="世界书名称")
|
||||||
|
description: Optional[str] = Field(None, description="世界书描述")
|
||||||
|
entries: List[WorldInfoEntry] = 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="版本号 (用于数据迁移)")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 角色卡 (Character Card) ====================
|
||||||
|
|
||||||
|
class OutputSchemaField(BaseModel):
|
||||||
|
"""
|
||||||
|
Vercel AI SDK Output.object() 的表头定义
|
||||||
|
|
||||||
|
用于结构化输出,让 LLM 按照指定格式返回数据。
|
||||||
|
这是项目的特色功能,支持动态表格生成。
|
||||||
|
"""
|
||||||
|
name: str = Field(..., description="字段名称")
|
||||||
|
type: str = Field(..., description="字段类型 (string/number/boolean/array/object)")
|
||||||
|
description: str = Field(..., description="字段描述")
|
||||||
|
required: Optional[bool] = Field(None, description="是否必需")
|
||||||
|
enum: Optional[List[str]] = Field(None, description="枚举值 (字符串固定选项)")
|
||||||
|
fields: Optional[List['OutputSchemaField']] = Field(None, description="嵌套字段 (object 类型)")
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCard(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部角色卡结构
|
||||||
|
|
||||||
|
角色卡是对话 AI 的核心定义,包含人设、场景、开场白等。
|
||||||
|
相比 SillyTavern,我们添加了 categories、outputSchema、worldInfoId 等功能。
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="角色唯一标识符 (UUID)")
|
||||||
|
name: str = Field(..., description="角色名称")
|
||||||
|
description: str = Field(..., description="角色详细描述")
|
||||||
|
personality: str = Field(..., description="角色性格特征")
|
||||||
|
scenario: str = Field(..., description="场景设定")
|
||||||
|
first_mes: str = Field(..., description="首条开场消息")
|
||||||
|
mes_example: str = Field(..., description="对话示例")
|
||||||
|
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
||||||
|
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
||||||
|
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
||||||
|
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
||||||
|
alternate_greetings: Optional[List[str]] = Field(None, description="替代问候语数组")
|
||||||
|
tags: Optional[List[str]] = Field(None, description="标签数组")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||||
|
isFavorite: bool = Field(False, description="收藏状态")
|
||||||
|
version: int = Field(1, description="版本号")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 聊天记录 (Chat Log) ====================
|
||||||
|
|
||||||
|
class ChatHeader(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部聊天记录头
|
||||||
|
|
||||||
|
包含聊天的元数据,如参与角色、创建时间等。
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||||
|
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||||
|
characterId: str = Field(..., description="关联的角色卡 ID")
|
||||||
|
userName: str = Field("User", description="用户角色名")
|
||||||
|
characterName: str = Field(..., description="AI 角色名称")
|
||||||
|
tableData: Optional[Dict[str, Any]] = Field(None, description="表格数据 (对应 outputSchema)")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
messageCount: int = Field(0, description="消息数量")
|
||||||
|
ragLibraryId: Optional[str] = Field(None, description="关联的 RAG 历史消息库 ID")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部聊天消息
|
||||||
|
|
||||||
|
单条对话消息,支持多版本 (swipes)、token 统计等功能。
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||||
|
name: str = Field(..., description="发送者名称")
|
||||||
|
is_user: bool = Field(..., description="是否为用户消息")
|
||||||
|
is_system: Optional[bool] = Field(None, description="是否为系统消息")
|
||||||
|
sendDate: str = Field(..., description="发送日期 ISO 字符串")
|
||||||
|
mes: str = Field(..., description="消息内容文本")
|
||||||
|
chatId: str = Field(..., description="关联的聊天 ID")
|
||||||
|
swipes: Optional[List[str]] = Field(None, description="替换回答数组 (多版本)")
|
||||||
|
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
||||||
|
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||||
|
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatLog(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部完整聊天记录
|
||||||
|
|
||||||
|
包含聊天头和所有消息,是完整的对话历史。
|
||||||
|
"""
|
||||||
|
header: ChatHeader = Field(..., description="聊天头")
|
||||||
|
messages: List[ChatMessage] = Field(default_factory=list, description="消息列表")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 预设 (Preset) ====================
|
||||||
|
|
||||||
|
class GenerationPreset(BaseModel):
|
||||||
|
"""
|
||||||
|
项目内部采样参数预设
|
||||||
|
|
||||||
|
控制 LLM 生成的参数配置,如温度、top_p 等。
|
||||||
|
"""
|
||||||
|
id: str = Field(..., description="预设唯一标识符 (UUID)")
|
||||||
|
name: str = Field(..., description="预设名称")
|
||||||
|
temperature: float = Field(1.0, ge=0, le=2, description="温度 (控制随机性)")
|
||||||
|
topP: float = Field(1.0, ge=0, le=1, description="Top P (核采样)")
|
||||||
|
topK: int = Field(0, ge=0, description="Top K")
|
||||||
|
repetitionPenalty: float = Field(1.0, ge=0, description="重复惩罚")
|
||||||
|
frequencyPenalty: Optional[float] = Field(None, description="频率惩罚")
|
||||||
|
presencePenalty: Optional[float] = Field(None, description="存在惩罚")
|
||||||
|
maxLength: Optional[int] = Field(None, gt=0, description="最大生成长度")
|
||||||
|
isDefault: bool = Field(False, description="是否为默认预设")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 提示词预设 (Prompt Preset) ====================
|
||||||
|
|
||||||
|
class PromptRole(str, Enum):
|
||||||
|
"""
|
||||||
|
Prompt 角色类型
|
||||||
|
|
||||||
|
内部业务层只保留三种角色,简化了 SillyTavern 的复杂角色系统。
|
||||||
|
"""
|
||||||
|
SYSTEM = 'system' # 系统指令
|
||||||
|
AI = 'ai' # AI 助手
|
||||||
|
USER = 'user' # 用户
|
||||||
|
|
||||||
|
|
||||||
|
class PromptEntry(BaseModel):
|
||||||
|
"""
|
||||||
|
内部业务层 - Prompt 条目
|
||||||
|
|
||||||
|
提示词模板的基本单元,可以组合成完整的提示词预设。
|
||||||
|
这是基于某个 character_id 生成的"当前视图"。
|
||||||
|
"""
|
||||||
|
identifier: str = Field(..., description="稳定关联键 (用于回写)")
|
||||||
|
name: str = Field(..., description="条目名 (前端显示)")
|
||||||
|
enabled: bool = Field(True, description="是否启用 (当前作用域下的业务状态)")
|
||||||
|
content: str = Field(..., description="条目内容 (静态内容视图)")
|
||||||
|
order: int = Field(..., description="条目顺序 (前端展示和拖拽排序)")
|
||||||
|
role: PromptRole = Field(..., description="角色类型")
|
||||||
|
tokenCount: int = Field(0, description="总 token 数 (派生显示字段)")
|
||||||
|
isSystemNode: bool = Field(False, description="是否固有节点 (不可删除)")
|
||||||
|
|
||||||
|
|
||||||
|
class PromptPresetView(BaseModel):
|
||||||
|
"""
|
||||||
|
内部业务层 - Prompt 预设视图
|
||||||
|
|
||||||
|
基于某个 character_id 的"当前视图",包含已排序、已过滤的条目列表。
|
||||||
|
"""
|
||||||
|
characterId: str = Field(..., description="关联的角色 ID")
|
||||||
|
entries: List[PromptEntry] = Field(default_factory=list, description="当前视图的条目列表")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
version: int = Field(1, description="版本号")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== RAG 配置 ====================
|
||||||
|
|
||||||
|
class RAGSearchConfig(BaseModel):
|
||||||
|
"""RAG 搜索配置"""
|
||||||
|
topK: int = Field(5, gt=0, description="每次检索返回的结果数")
|
||||||
|
threshold: float = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
|
||||||
|
maxContextLength: int = Field(2000, gt=0, description="最大上下文长度 (字符数)")
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterRAGConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
角色卡 RAG 世界书库配置
|
||||||
|
|
||||||
|
记录角色卡关联的 RAG 知识库,用于动态检索相关知识。
|
||||||
|
"""
|
||||||
|
characterId: str = Field(..., description="角色卡ID")
|
||||||
|
ragLibraryIds: List[str] = Field(default_factory=list, description="关联的RAG库ID列表")
|
||||||
|
enabled: bool = Field(True, description="是否启用")
|
||||||
|
searchConfig: Optional[RAGSearchConfig] = Field(None, description="搜索配置")
|
||||||
|
position: str = Field('after_char', description="RAG内容插入位置")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRAGConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
聊天会话 RAG 历史消息配置
|
||||||
|
|
||||||
|
记录聊天会话关联的 RAG 历史消息库,用于智能检索历史对话。
|
||||||
|
"""
|
||||||
|
chatId: str = Field(..., description="聊天会话ID")
|
||||||
|
ragLibraryId: Optional[str] = Field(None, description="关联的RAG历史消息库ID")
|
||||||
|
enabled: bool = Field(True, description="是否启用")
|
||||||
|
searchConfig: Optional[Dict[str, Any]] = Field(None, description="搜索配置")
|
||||||
|
autoIndex: bool = Field(True, description="是否自动索引新消息")
|
||||||
|
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
|
||||||
|
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||||
|
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import re
|
|
||||||
from core.node_base import BaseNode
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class TextSplitterNode(BaseNode):
|
|
||||||
name = "文本分割节点"
|
|
||||||
inputs = {"text": "string"}
|
|
||||||
outputs = {
|
|
||||||
"outline": "list", # 大纲部分列表
|
|
||||||
"requirement": "list", # 要求部分列表
|
|
||||||
"dialogue": "list", # 对话部分列表
|
|
||||||
"weak_guidance": "list" # 弱指引部分列表
|
|
||||||
}
|
|
||||||
|
|
||||||
async def run(self, text: str) -> Dict[str, List[str]]:
|
|
||||||
# 正则匹配三种括号内的内容
|
|
||||||
# 注意:此正则假设括号不嵌套,且没有转义字符
|
|
||||||
pattern = r'\{([^{}]*)\}|\(([^()]*)\)|“([^”]*)”'
|
|
||||||
|
|
||||||
outline = []
|
|
||||||
requirement = []
|
|
||||||
dialogue = []
|
|
||||||
weak_guidance = []
|
|
||||||
|
|
||||||
pos = 0
|
|
||||||
for match in re.finditer(pattern, text):
|
|
||||||
start, end = match.span()
|
|
||||||
# 处理匹配前的普通文本(弱指引)
|
|
||||||
if start > pos:
|
|
||||||
weak_part = text[pos:start].strip()
|
|
||||||
if weak_part:
|
|
||||||
weak_guidance.append(weak_part)
|
|
||||||
|
|
||||||
# 根据捕获组确定类型
|
|
||||||
if match.group(1) is not None: # 大括号
|
|
||||||
outline.append(match.group(1).strip())
|
|
||||||
elif match.group(2) is not None: # 小括号
|
|
||||||
requirement.append(match.group(2).strip())
|
|
||||||
elif match.group(3) is not None: # 中文引号
|
|
||||||
dialogue.append(match.group(3).strip())
|
|
||||||
|
|
||||||
pos = end
|
|
||||||
|
|
||||||
# 处理剩余的普通文本
|
|
||||||
if pos < len(text):
|
|
||||||
weak_part = text[pos:].strip()
|
|
||||||
if weak_part:
|
|
||||||
weak_guidance.append(weak_part)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"outline": outline,
|
|
||||||
"requirement": requirement,
|
|
||||||
"dialogue": dialogue,
|
|
||||||
"weak_guidance": weak_guidance
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
fastapi==0.104.1
|
fastapi==0.104.1
|
||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]==0.24.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
|
cryptography>=41.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|
||||||
|
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
||||||
|
langchain>=0.1.0
|
||||||
|
langchain-openai>=0.0.5
|
||||||
|
langchain-anthropic>=0.1.1
|
||||||
|
openai>=1.12.0
|
||||||
|
anthropic>=0.23.0
|
||||||
11
backend/services/__init__.py
Normal file
11
backend/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""
|
||||||
|
业务服务层
|
||||||
|
|
||||||
|
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
||||||
|
"""
|
||||||
|
from .prompt_assembler import PromptAssembler, PromptConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PromptAssembler',
|
||||||
|
'PromptConfig',
|
||||||
|
]
|
||||||
166
backend/services/character_card_converter.py
Normal file
166
backend/services/character_card_converter.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
角色卡格式转换器
|
||||||
|
支持 SillyTavern V2/V3 格式与内部格式的双向转换
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.models.internal import CharacterCard
|
||||||
|
except ImportError:
|
||||||
|
from models.internal import CharacterCard
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCardConverter:
|
||||||
|
"""角色卡格式转换器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def st_to_internal(st_data: dict, avatar_path: Optional[str] = None) -> CharacterCard:
|
||||||
|
"""
|
||||||
|
SillyTavern 格式 → 内部格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
st_data: SillyTavern 角色卡数据(V2/V3)
|
||||||
|
avatar_path: 头像路径(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CharacterCard 对象
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 兼容两种传入方式:完整ST格式或直接data
|
||||||
|
if 'spec' in st_data:
|
||||||
|
data = st_data.get('data', {})
|
||||||
|
else:
|
||||||
|
data = st_data
|
||||||
|
|
||||||
|
extensions = data.get('extensions', {})
|
||||||
|
|
||||||
|
return CharacterCard(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=data['name'],
|
||||||
|
description=data.get('description', ''),
|
||||||
|
personality=data.get('personality', ''),
|
||||||
|
scenario=data.get('scenario', ''),
|
||||||
|
first_mes=data.get('first_mes', ''),
|
||||||
|
mes_example=data.get('mes_example', ''),
|
||||||
|
categories=[], # ST没有categories
|
||||||
|
worldInfoId=extensions.get('world'),
|
||||||
|
outputSchema=None, # ST不支持结构化输出
|
||||||
|
avatarPath=avatar_path,
|
||||||
|
alternate_greetings=data.get('alternate_greetings', []),
|
||||||
|
tags=data.get('tags', []),
|
||||||
|
createdAt=int(datetime.now().timestamp()),
|
||||||
|
updatedAt=int(datetime.now().timestamp()),
|
||||||
|
lastChatAt=None,
|
||||||
|
isFavorite=extensions.get('fav', False),
|
||||||
|
version=1
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def internal_to_st(character: CharacterCard) -> dict:
|
||||||
|
"""
|
||||||
|
内部格式 → SillyTavern V3 格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: CharacterCard 对象
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SillyTavern V3 格式字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"spec": "chara_card_v3",
|
||||||
|
"spec_version": "3.0",
|
||||||
|
"data": {
|
||||||
|
"name": character.name,
|
||||||
|
"description": character.description,
|
||||||
|
"personality": character.personality,
|
||||||
|
"scenario": character.scenario,
|
||||||
|
"first_mes": character.first_mes,
|
||||||
|
"mes_example": character.mes_example,
|
||||||
|
"alternate_greetings": character.alternate_greetings or [],
|
||||||
|
"tags": character.tags or [],
|
||||||
|
"creator_notes": "",
|
||||||
|
"system_prompt": "",
|
||||||
|
"post_history_instructions": "",
|
||||||
|
"extensions": {
|
||||||
|
"world": character.worldInfoId,
|
||||||
|
"talkativeness": 0.5,
|
||||||
|
"fav": character.isFavorite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_as_png(character: CharacterCard, avatar_path: Optional[str] = None, use_default_avatar: bool = False) -> bytes:
|
||||||
|
"""
|
||||||
|
导出为 SillyTavern PNG 格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character: CharacterCard 对象
|
||||||
|
avatar_path: 头像图片路径(可选)
|
||||||
|
use_default_avatar: 是否使用默认头像(不嵌入JSON数据)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG 文件的二进制数据
|
||||||
|
"""
|
||||||
|
# 1. 创建/加载图片
|
||||||
|
if avatar_path and Path(avatar_path).exists():
|
||||||
|
img = Image.open(avatar_path)
|
||||||
|
else:
|
||||||
|
# 创建默认图片(400x600像素,灰色背景)
|
||||||
|
img = Image.new('RGB', (400, 600), color=(73, 109, 137))
|
||||||
|
|
||||||
|
# 确保是 RGBA 模式
|
||||||
|
if img.mode != 'RGBA':
|
||||||
|
img = img.convert('RGBA')
|
||||||
|
|
||||||
|
# 2. 如果不是默认头像,才嵌入 JSON 数据
|
||||||
|
if not use_default_avatar:
|
||||||
|
st_data = CharacterCardConverter.internal_to_st(character)
|
||||||
|
json_str = json.dumps(st_data, ensure_ascii=False)
|
||||||
|
base64_data = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
|
||||||
|
img.text['ccv3'] = base64_data
|
||||||
|
|
||||||
|
# 3. 保存到字节流
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return buffer.read()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def extract_from_png(png_data: bytes) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
从 PNG 文件中提取嵌入的角色数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
png_data: PNG 文件的二进制数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SillyTavern 格式字典,如果没有嵌入数据则返回 None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(png_data))
|
||||||
|
|
||||||
|
# 尝试 V3 格式 (ccv3)
|
||||||
|
if 'ccv3' in img.text:
|
||||||
|
json_str = base64.b64decode(img.text['ccv3']).decode('utf-8')
|
||||||
|
return json.loads(json_str)
|
||||||
|
|
||||||
|
# 尝试 V2 格式 (chara)
|
||||||
|
elif 'chara' in img.text:
|
||||||
|
json_str = base64.b64decode(img.text['chara']).decode('utf-8')
|
||||||
|
return json.loads(json_str)
|
||||||
|
|
||||||
|
# 没有嵌入数据
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"解析PNG失败: {e}")
|
||||||
|
return None
|
||||||
318
backend/services/character_service.py
Normal file
318
backend/services/character_service.py
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
角色卡服务 - 严格按照 internal.py 的数据结构
|
||||||
|
每个角色一个文件夹,包含 character.json、avatar.png 和 chats/
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.models.internal import CharacterCard
|
||||||
|
from backend.core.config import settings
|
||||||
|
from backend.services.character_card_converter import CharacterCardConverter
|
||||||
|
except ImportError:
|
||||||
|
from models.internal import CharacterCard
|
||||||
|
from core.config import settings
|
||||||
|
from services.character_card_converter import CharacterCardConverter
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterService:
|
||||||
|
"""角色卡管理服务"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.characters_dir = settings.CHARACTERS_PATH
|
||||||
|
self.converter = CharacterCardConverter()
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
self.characters_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def scan_all_characters(self) -> List[CharacterCard]:
|
||||||
|
"""
|
||||||
|
扫描所有角色卡
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
按 lastChatAt 排序的角色卡列表(最新的在前)
|
||||||
|
"""
|
||||||
|
characters = []
|
||||||
|
|
||||||
|
for char_folder in self.characters_dir.iterdir():
|
||||||
|
if not char_folder.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
character = self._load_character_from_folder(char_folder)
|
||||||
|
if character:
|
||||||
|
characters.append(character)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"加载角色卡失败 {char_folder.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 按最后聊天时间排序(None 排最后)
|
||||||
|
characters.sort(
|
||||||
|
key=lambda c: c.lastChatAt or 0,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return characters
|
||||||
|
|
||||||
|
def _load_character_from_folder(self, folder: Path) -> Optional[CharacterCard]:
|
||||||
|
"""
|
||||||
|
从文件夹加载角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: 角色文件夹路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CharacterCard 对象或 None
|
||||||
|
"""
|
||||||
|
# 1. 读取 character.json(必须存在)
|
||||||
|
char_file = folder / "character.json"
|
||||||
|
if not char_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(char_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# 2. 检查是否有 avatar.png
|
||||||
|
avatar_path = None
|
||||||
|
avatar_file = folder / "avatar.png"
|
||||||
|
if avatar_file.exists():
|
||||||
|
# 存储相对路径,用于前端访问
|
||||||
|
avatar_path = f"/api/characters/{folder.name}/avatar"
|
||||||
|
|
||||||
|
# 3. 计算最后聊天时间
|
||||||
|
last_chat_at = self._get_last_chat_timestamp(folder)
|
||||||
|
|
||||||
|
# 4. 构建 CharacterCard 对象(严格按照数据结构)
|
||||||
|
character = CharacterCard(
|
||||||
|
id=data.get('id', str(uuid.uuid4())),
|
||||||
|
name=data['name'],
|
||||||
|
description=data.get('description', ''),
|
||||||
|
personality=data.get('personality', ''),
|
||||||
|
scenario=data.get('scenario', ''),
|
||||||
|
first_mes=data.get('first_mes', ''),
|
||||||
|
mes_example=data.get('mes_example', ''),
|
||||||
|
categories=data.get('categories', []),
|
||||||
|
worldInfoId=data.get('worldInfoId'),
|
||||||
|
outputSchema=data.get('outputSchema'),
|
||||||
|
avatarPath=avatar_path,
|
||||||
|
alternate_greetings=data.get('alternate_greetings', []),
|
||||||
|
tags=data.get('tags', []),
|
||||||
|
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||||
|
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||||
|
lastChatAt=last_chat_at,
|
||||||
|
isFavorite=data.get('isFavorite', False),
|
||||||
|
version=data.get('version', 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return character
|
||||||
|
|
||||||
|
def _get_last_chat_timestamp(self, char_folder: Path) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
获取角色的最后聊天时间戳
|
||||||
|
|
||||||
|
通过扫描 chats 目录下所有 .jsonl 文件的修改时间
|
||||||
|
"""
|
||||||
|
chats_dir = char_folder / "chats"
|
||||||
|
if not chats_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_time = None
|
||||||
|
|
||||||
|
for chat_file in chats_dir.glob("*.jsonl"):
|
||||||
|
file_mtime = int(chat_file.stat().st_mtime)
|
||||||
|
if latest_time is None or file_mtime > latest_time:
|
||||||
|
latest_time = file_mtime
|
||||||
|
|
||||||
|
return latest_time
|
||||||
|
|
||||||
|
def get_character_by_name(self, name: str) -> Optional[CharacterCard]:
|
||||||
|
"""根据角色名获取角色卡"""
|
||||||
|
char_folder = self.characters_dir / name
|
||||||
|
if not char_folder.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._load_character_from_folder(char_folder)
|
||||||
|
|
||||||
|
def create_character(self, character_data: dict) -> CharacterCard:
|
||||||
|
"""
|
||||||
|
创建新角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
character_data: 角色数据字典
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的 CharacterCard 对象
|
||||||
|
"""
|
||||||
|
# 生成唯一ID
|
||||||
|
if 'id' not in character_data:
|
||||||
|
character_data['id'] = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 设置时间戳
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
character_data['createdAt'] = now
|
||||||
|
character_data['updatedAt'] = now
|
||||||
|
character_data['lastChatAt'] = None
|
||||||
|
|
||||||
|
# 创建文件夹
|
||||||
|
char_name = character_data['name']
|
||||||
|
char_folder = self.characters_dir / char_name
|
||||||
|
char_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 创建 chats 目录
|
||||||
|
chats_dir = char_folder / "chats"
|
||||||
|
chats_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 保存 character.json
|
||||||
|
char_file = char_folder / "character.json"
|
||||||
|
with open(char_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(character_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return self._load_character_from_folder(char_folder)
|
||||||
|
|
||||||
|
def update_character(self, name: str, updates: dict) -> CharacterCard:
|
||||||
|
"""
|
||||||
|
更新角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
updates: 更新的字段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的 CharacterCard 对象
|
||||||
|
"""
|
||||||
|
char_folder = self.characters_dir / name
|
||||||
|
char_file = char_folder / "character.json"
|
||||||
|
|
||||||
|
if not char_file.exists():
|
||||||
|
raise FileNotFoundError(f"角色卡不存在: {name}")
|
||||||
|
|
||||||
|
# 读取现有数据
|
||||||
|
with open(char_file, 'r', encoding='utf-8') as f:
|
||||||
|
existing_data = json.load(f)
|
||||||
|
|
||||||
|
# 合并更新
|
||||||
|
existing_data.update(updates)
|
||||||
|
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||||||
|
|
||||||
|
# 保存
|
||||||
|
with open(char_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(existing_data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
return self._load_character_from_folder(char_folder)
|
||||||
|
|
||||||
|
def delete_character(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除角色卡(包括所有聊天记录)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否成功删除
|
||||||
|
"""
|
||||||
|
char_folder = self.characters_dir / name
|
||||||
|
if not char_folder.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(char_folder)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def save_avatar(self, name: str, image_data: bytes) -> str:
|
||||||
|
"""
|
||||||
|
保存角色头像
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
image_data: 图片二进制数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
头像访问路径
|
||||||
|
"""
|
||||||
|
char_folder = self.characters_dir / name
|
||||||
|
avatar_file = char_folder / "avatar.png"
|
||||||
|
|
||||||
|
with open(avatar_file, 'wb') as f:
|
||||||
|
f.write(image_data)
|
||||||
|
|
||||||
|
return f"/api/characters/{name}/avatar"
|
||||||
|
|
||||||
|
def import_from_png(self, png_data: bytes, filename: str) -> CharacterCard:
|
||||||
|
"""
|
||||||
|
从 SillyTavern PNG 导入角色卡
|
||||||
|
|
||||||
|
Args:
|
||||||
|
png_data: PNG 文件二进制数据
|
||||||
|
filename: 原始文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的 CharacterCard 对象
|
||||||
|
"""
|
||||||
|
# 1. 提取嵌入数据
|
||||||
|
st_data = self.converter.extract_from_png(png_data)
|
||||||
|
if not st_data:
|
||||||
|
raise ValueError("PNG文件中没有嵌入角色数据")
|
||||||
|
|
||||||
|
# 2. 转换为内部格式
|
||||||
|
character = self.converter.st_to_internal(st_data)
|
||||||
|
|
||||||
|
# 3. 创建角色文件夹
|
||||||
|
char_name = character.name
|
||||||
|
char_folder = self.characters_dir / char_name
|
||||||
|
char_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 4. 保存 PNG 作为 avatar.png
|
||||||
|
avatar_file = char_folder / "avatar.png"
|
||||||
|
with open(avatar_file, 'wb') as f:
|
||||||
|
f.write(png_data)
|
||||||
|
|
||||||
|
# 5. 保存 character.json
|
||||||
|
char_file = char_folder / "character.json"
|
||||||
|
with open(char_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(character.dict(), f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# 6. 创建 chats 目录
|
||||||
|
(char_folder / "chats").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
return character
|
||||||
|
|
||||||
|
def export_as_png(self, name: str) -> bytes:
|
||||||
|
"""
|
||||||
|
导出角色为 SillyTavern PNG 格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 角色名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PNG 文件二进制数据
|
||||||
|
"""
|
||||||
|
character = self.get_character_by_name(name)
|
||||||
|
if not character:
|
||||||
|
raise FileNotFoundError(f"角色 '{name}' 不存在")
|
||||||
|
|
||||||
|
# 获取头像路径
|
||||||
|
avatar_path = None
|
||||||
|
if character.avatarPath:
|
||||||
|
# 从路径中提取文件名
|
||||||
|
avatar_filename = character.avatarPath.split('/')[-1].split('?')[0]
|
||||||
|
char_folder = self.characters_dir / name
|
||||||
|
avatar_file = char_folder / avatar_filename
|
||||||
|
if avatar_file.exists():
|
||||||
|
avatar_path = str(avatar_file)
|
||||||
|
|
||||||
|
# 如果没有头像,使用默认图片
|
||||||
|
use_default = False
|
||||||
|
if not avatar_path:
|
||||||
|
default_avatar = self.characters_dir / "defult.png"
|
||||||
|
if default_avatar.exists():
|
||||||
|
avatar_path = str(default_avatar)
|
||||||
|
use_default = True
|
||||||
|
print(f"使用默认头像: {avatar_path}")
|
||||||
|
else:
|
||||||
|
print("警告: 没有找到默认头像")
|
||||||
|
|
||||||
|
# 生成 PNG
|
||||||
|
return self.converter.export_as_png(character, avatar_path, use_default_avatar=use_default)
|
||||||
383
backend/services/chat_service.py
Normal file
383
backend/services/chat_service.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
"""
|
||||||
|
聊天服务 - 处理聊天记录的读写操作
|
||||||
|
|
||||||
|
基于 SillyTavern JSONL 格式的聊天记录管理
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
"""聊天服务类,处理聊天记录的CRUD操作"""
|
||||||
|
|
||||||
|
def __init__(self, data_path: Path):
|
||||||
|
"""
|
||||||
|
初始化聊天服务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_path: 数据目录路径
|
||||||
|
"""
|
||||||
|
self.data_path = data_path
|
||||||
|
self.chat_dir = data_path / "chat"
|
||||||
|
self.chat_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def list_all_chats(self) -> Dict[str, List[Dict]]:
|
||||||
|
"""
|
||||||
|
获取所有角色和聊天列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, List[Dict]]: 字典结构,键是角色名称,值是该角色的聊天信息列表
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if not self.chat_dir.exists():
|
||||||
|
logger.warning(f"聊天目录不存在: {self.chat_dir}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
for role_dir in self.chat_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if role_dir.is_dir():
|
||||||
|
chats = []
|
||||||
|
|
||||||
|
for chat_file in role_dir.glob("*.jsonl"):
|
||||||
|
chat_info = self._get_chat_summary(role_dir.name, chat_file.stem)
|
||||||
|
if chat_info:
|
||||||
|
chats.append(chat_info)
|
||||||
|
|
||||||
|
if chats:
|
||||||
|
result[role_dir.name] = chats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
|
||||||
|
|
||||||
|
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||||
|
"""
|
||||||
|
获取聊天摘要信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 第一行是header
|
||||||
|
header = json.loads(lines[0])
|
||||||
|
|
||||||
|
# 计算消息数量(排除header)
|
||||||
|
message_count = len(lines) - 1
|
||||||
|
|
||||||
|
# 获取最后修改时间
|
||||||
|
last_modified = datetime.fromtimestamp(
|
||||||
|
chat_file.stat().st_mtime
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
# 获取最后一条消息预览
|
||||||
|
last_message = ""
|
||||||
|
if message_count > 0:
|
||||||
|
try:
|
||||||
|
last_msg_data = json.loads(lines[-1])
|
||||||
|
last_message = last_msg_data.get("mes", "")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chat_name": chat_name,
|
||||||
|
"user_name": header.get("user_name", "User"),
|
||||||
|
"character_name": header.get("character_name", ""),
|
||||||
|
"last_modified": last_modified,
|
||||||
|
"message_count": message_count,
|
||||||
|
"last_message": last_message
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取聊天摘要失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_chat(self, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取指定聊天的完整内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 包含metadata和messages的字典
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
# 解析消息
|
||||||
|
messages = []
|
||||||
|
for i, line in enumerate(lines[1:], start=1):
|
||||||
|
if line.strip(): # 跳过空行
|
||||||
|
msg_data = json.loads(line)
|
||||||
|
# 确保有floor字段
|
||||||
|
if "floor" not in msg_data:
|
||||||
|
msg_data["floor"] = i
|
||||||
|
|
||||||
|
messages.append(msg_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"metadata": {
|
||||||
|
"user_name": header.get("user_name", "User"),
|
||||||
|
"character_name": header.get("character_name", ""),
|
||||||
|
"chat_id": header.get("chat_id_hash", ""),
|
||||||
|
"integrity": header.get("integrity", "")
|
||||||
|
},
|
||||||
|
"messages": messages
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
|
||||||
|
"""
|
||||||
|
创建新聊天
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
metadata: 聊天元数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 创建的聊天信息
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileExistsError: 聊天已存在
|
||||||
|
"""
|
||||||
|
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||||
|
|
||||||
|
if chat_file.exists():
|
||||||
|
raise FileExistsError(f"Chat already exists: {role_name}/{chat_name}")
|
||||||
|
|
||||||
|
# 创建角色目录
|
||||||
|
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 构建header
|
||||||
|
header = {
|
||||||
|
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||||
|
"character_name": metadata.get("character_name", role_name) if metadata else role_name,
|
||||||
|
"integrity": str(uuid.uuid4()),
|
||||||
|
"chat_id_hash": str(uuid.uuid4()),
|
||||||
|
"note_prompt": "",
|
||||||
|
"note_interval": 0,
|
||||||
|
"note_position": 0,
|
||||||
|
"note_depth": 0,
|
||||||
|
"note_role": 0,
|
||||||
|
"extensions": {},
|
||||||
|
"timedWorldInfo": {},
|
||||||
|
"variables": {},
|
||||||
|
"tainted": False,
|
||||||
|
"lastInContextMessageId": -1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 写入header
|
||||||
|
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(json.dumps(header, ensure_ascii=False) + '\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
"role_name": role_name,
|
||||||
|
"chat_name": chat_name,
|
||||||
|
"metadata": {
|
||||||
|
"user_name": header["user_name"],
|
||||||
|
"character_name": header["character_name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_message(self, role_name: str, chat_name: str, message_data: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
向聊天添加新消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
message_data: 消息数据
|
||||||
|
|
||||||
|
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:
|
||||||
|
# 读取现有消息以确定floor
|
||||||
|
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# 计算下一个floor号
|
||||||
|
next_floor = len(lines) - 1 # 减去header行
|
||||||
|
|
||||||
|
# 构建完整的消息数据
|
||||||
|
full_message = {
|
||||||
|
"name": message_data.get("name", "User"),
|
||||||
|
"is_user": message_data.get("is_user", True),
|
||||||
|
"is_system": message_data.get("is_system", False),
|
||||||
|
"floor": next_floor,
|
||||||
|
"send_date": message_data.get("send_date", str(int(datetime.now().timestamp() * 1000))),
|
||||||
|
"mes": message_data.get("mes", ""),
|
||||||
|
"extra": message_data.get("extra", {}),
|
||||||
|
"swipes": message_data.get("swipes", []),
|
||||||
|
"swipe_id": message_data.get("swipe_id", 0),
|
||||||
|
"force_avatar": None,
|
||||||
|
"variables": [],
|
||||||
|
"variables_initialized": [],
|
||||||
|
"is_ejs_processed": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 追加消息到文件
|
||||||
|
with open(chat_file, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(json.dumps(full_message, ensure_ascii=False) + '\n')
|
||||||
|
|
||||||
|
return full_message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"添加消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_message(self, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
更新指定楼层的消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
floor: 楼层号
|
||||||
|
update_data: 更新的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 更新后的消息
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 找到对应的消息行(floor + 1,因为第0行是header)
|
||||||
|
message_line_index = floor + 1
|
||||||
|
|
||||||
|
if message_line_index >= len(lines):
|
||||||
|
raise ValueError(f"Floor {floor} not found in chat")
|
||||||
|
|
||||||
|
# 解析并更新消息
|
||||||
|
msg_data = json.loads(lines[message_line_index])
|
||||||
|
msg_data.update(update_data)
|
||||||
|
|
||||||
|
# 写回文件
|
||||||
|
lines[message_line_index] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||||
|
|
||||||
|
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
return msg_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"更新消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||||
|
"""
|
||||||
|
删除指定楼层的消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_name: 角色名称
|
||||||
|
chat_name: 聊天名称
|
||||||
|
floor: 楼层号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: 被删除的消息
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 找到对应的消息行
|
||||||
|
message_line_index = floor + 1
|
||||||
|
|
||||||
|
if message_line_index >= len(lines):
|
||||||
|
raise ValueError(f"Floor {floor} not found in chat")
|
||||||
|
|
||||||
|
# 保存被删除的消息
|
||||||
|
deleted_msg = json.loads(lines[message_line_index])
|
||||||
|
|
||||||
|
# 删除该行
|
||||||
|
del lines[message_line_index]
|
||||||
|
|
||||||
|
# 重新编号后续消息的floor
|
||||||
|
for i in range(message_line_index, len(lines)):
|
||||||
|
if lines[i].strip(): # 跳过空行
|
||||||
|
msg_data = json.loads(lines[i])
|
||||||
|
msg_data["floor"] = i - 1 # 重新计算floor
|
||||||
|
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||||
|
|
||||||
|
# 写回文件
|
||||||
|
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
return deleted_msg
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||||
|
raise
|
||||||
173
backend/services/comfyui_workflow_manager.py
Normal file
173
backend/services/comfyui_workflow_manager.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
ComfyUI Workflow Manager
|
||||||
|
管理工作流 JSON 文件的上传、删除和加载
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from fastapi import UploadFile, HTTPException
|
||||||
|
import shutil
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
# 工作流目录 - 使用统一的数据目录
|
||||||
|
WORKFLOW_DIR = settings.COMFYUI_WORKFLOWS_PATH
|
||||||
|
WORKFLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowManager:
|
||||||
|
"""ComfyUI 工作流管理器"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_workflows() -> List[Dict[str, str]]:
|
||||||
|
"""列出所有可用的工作流"""
|
||||||
|
workflows = []
|
||||||
|
|
||||||
|
for json_file in WORKFLOW_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
|
workflow_data = json.load(f)
|
||||||
|
|
||||||
|
workflows.append({
|
||||||
|
"filename": json_file.name,
|
||||||
|
"name": json_file.stem,
|
||||||
|
"nodes_count": len(workflow_data),
|
||||||
|
"size": json_file.stat().st_size
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading workflow {json_file.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return workflows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load_workflow(filename: str) -> Dict:
|
||||||
|
"""加载指定工作流"""
|
||||||
|
filepath = WORKFLOW_DIR / filename
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
|
||||||
|
|
||||||
|
if not filepath.suffix == '.json':
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid file type")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Invalid JSON: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def upload_workflow(file: UploadFile) -> Dict[str, str]:
|
||||||
|
"""上传工作流文件"""
|
||||||
|
# 验证文件名
|
||||||
|
if not file.filename or not file.filename.endswith('.json'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a JSON file")
|
||||||
|
|
||||||
|
# 安全检查:防止路径遍历攻击
|
||||||
|
safe_filename = os.path.basename(file.filename)
|
||||||
|
if not safe_filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
filepath = WORKFLOW_DIR / safe_filename
|
||||||
|
|
||||||
|
# 如果文件已存在,先备份
|
||||||
|
if filepath.exists():
|
||||||
|
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
|
||||||
|
shutil.copy2(filepath, backup_path)
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# 验证 JSON 格式
|
||||||
|
try:
|
||||||
|
workflow_data = json.loads(content)
|
||||||
|
|
||||||
|
# 基本验证:检查是否是 ComfyUI 工作流
|
||||||
|
if not isinstance(workflow_data, dict):
|
||||||
|
raise ValueError("Workflow must be a JSON object")
|
||||||
|
|
||||||
|
# 检查是否包含必要的节点类型
|
||||||
|
has_sampler = any(
|
||||||
|
node.get("class_type") == "KSampler"
|
||||||
|
for node in workflow_data.values()
|
||||||
|
if isinstance(node, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_sampler:
|
||||||
|
raise ValueError("Invalid ComfyUI workflow: missing KSampler node")
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
# 写入文件
|
||||||
|
with open(filepath, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content.decode('utf-8'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Workflow uploaded successfully",
|
||||||
|
"filename": safe_filename,
|
||||||
|
"size": len(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# 如果出错,恢复备份
|
||||||
|
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
|
||||||
|
if backup_path.exists():
|
||||||
|
shutil.move(backup_path, filepath)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_workflow(filename: str) -> Dict[str, str]:
|
||||||
|
"""删除工作流文件"""
|
||||||
|
# 安全检查
|
||||||
|
safe_filename = os.path.basename(filename)
|
||||||
|
if not safe_filename or not safe_filename.endswith('.json'):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
filepath = WORKFLOW_DIR / safe_filename
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
|
||||||
|
|
||||||
|
# 不允许删除默认工作流
|
||||||
|
if safe_filename == "default_txt2img.json":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Cannot delete default workflow"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
filepath.unlink()
|
||||||
|
return {"message": f"Workflow '{safe_filename}' deleted successfully"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def replace_prompt_in_workflow(workflow: Dict, prompt: str) -> Dict:
|
||||||
|
"""
|
||||||
|
在工作流中替换提示词
|
||||||
|
找到第一个 CLIPTextEncode 节点,替换其 text 字段
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
workflow_copy = copy.deepcopy(workflow)
|
||||||
|
|
||||||
|
# 查找 CLIPTextEncode 节点(通常是正向提示词)
|
||||||
|
for node_id, node in workflow_copy.items():
|
||||||
|
if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode":
|
||||||
|
if "text" in node.get("inputs", {}):
|
||||||
|
# 替换提示词
|
||||||
|
node["inputs"]["text"] = prompt
|
||||||
|
return workflow_copy
|
||||||
|
|
||||||
|
# 如果没有找到 CLIPTextEncode 节点,抛出错误
|
||||||
|
raise ValueError("No CLIPTextEncode node found in workflow")
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
workflow_manager = WorkflowManager()
|
||||||
172
backend/services/llm_model_service.py
Normal file
172
backend/services/llm_model_service.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
"""
|
||||||
|
LLM 模型管理服务
|
||||||
|
|
||||||
|
提供获取不同 LLM 提供商可用模型列表的功能
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class LLMModelService:
|
||||||
|
"""LLM 模型管理服务"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_openai_models(api_key: str, base_url: Optional[str] = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
获取 OpenAI 兼容 API 的模型列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API Key
|
||||||
|
base_url: API 基础 URL,默认为 OpenAI 官方 API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模型名称列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 默认使用 OpenAI 官方 API
|
||||||
|
if not base_url:
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
# 确保 base_url 以 /v1 结尾
|
||||||
|
if not base_url.endswith('/v1'):
|
||||||
|
base_url = base_url.rstrip('/') + '/v1'
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
f"{base_url}/models",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
models = [model['id'] for model in data.get('data', [])]
|
||||||
|
|
||||||
|
# 过滤出聊天模型(可选)
|
||||||
|
chat_models = [
|
||||||
|
m for m in models
|
||||||
|
if any(keyword in m.lower() for keyword in ['gpt', 'chat'])
|
||||||
|
]
|
||||||
|
|
||||||
|
# 如果没有找到聊天模型,返回所有模型
|
||||||
|
return chat_models if chat_models else models
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_anthropic_models(api_key: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
获取 Anthropic Claude 模型列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API Key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模型名称列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Anthropic 没有公开的模型列表 API,返回已知模型
|
||||||
|
return [
|
||||||
|
"claude-3-5-sonnet-20241022",
|
||||||
|
"claude-3-5-haiku-20241022",
|
||||||
|
"claude-3-opus-20240229",
|
||||||
|
"claude-3-sonnet-20240229",
|
||||||
|
"claude-3-haiku-20240307",
|
||||||
|
"claude-2.1",
|
||||||
|
"claude-2.0",
|
||||||
|
"claude-instant-1.2"
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"获取 Anthropic 模型列表失败: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_ollama_models(base_url: str = "http://localhost:11434") -> List[str]:
|
||||||
|
"""
|
||||||
|
获取 Ollama 本地模型列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Ollama API 地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模型名称列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{base_url}/api/tags",
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
models = [model['name'] for model in data.get('models', [])]
|
||||||
|
|
||||||
|
return models
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"获取 Ollama 模型列表失败: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def detect_provider(api_url: str) -> str:
|
||||||
|
"""
|
||||||
|
根据 API URL 检测提供商类型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: API 地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提供商类型: 'openai', 'anthropic', 'ollama', 'unknown'
|
||||||
|
"""
|
||||||
|
api_url_lower = api_url.lower()
|
||||||
|
|
||||||
|
if 'openai' in api_url_lower or 'api.openai.com' in api_url_lower:
|
||||||
|
return 'openai'
|
||||||
|
elif 'anthropic' in api_url_lower or 'api.anthropic.com' in api_url_lower:
|
||||||
|
return 'anthropic'
|
||||||
|
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
|
||||||
|
return 'ollama'
|
||||||
|
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
|
||||||
|
# SiliconFlow 等兼容 OpenAI API 的服务
|
||||||
|
return 'openai'
|
||||||
|
elif 'deepseek' in api_url_lower:
|
||||||
|
# DeepSeek 等兼容 OpenAI API 的服务
|
||||||
|
return 'openai'
|
||||||
|
else:
|
||||||
|
# 默认尝试 OpenAI 兼容 API
|
||||||
|
return 'openai'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_models_by_provider(
|
||||||
|
provider: str,
|
||||||
|
api_key: str,
|
||||||
|
api_url: Optional[str] = None
|
||||||
|
) -> List[str]:
|
||||||
|
"""
|
||||||
|
根据提供商类型获取模型列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: 提供商类型 ('openai', 'anthropic', 'ollama')
|
||||||
|
api_key: API Key
|
||||||
|
api_url: API 地址(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模型名称列表
|
||||||
|
"""
|
||||||
|
if provider == 'openai':
|
||||||
|
return LLMModelService.get_openai_models(api_key, api_url)
|
||||||
|
elif provider == 'anthropic':
|
||||||
|
return LLMModelService.get_anthropic_models(api_key)
|
||||||
|
elif provider == 'ollama':
|
||||||
|
base_url = api_url or "http://localhost:11434"
|
||||||
|
# 移除 /v1 后缀(如果有)
|
||||||
|
base_url = base_url.replace('/v1', '').replace('/v1/', '')
|
||||||
|
return LLMModelService.get_ollama_models(base_url)
|
||||||
|
else:
|
||||||
|
raise Exception(f"不支持的提供商: {provider}")
|
||||||
221
backend/services/prompt_assembler.py
Normal file
221
backend/services/prompt_assembler.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
提示词组装器 (Prompt Assembler)
|
||||||
|
|
||||||
|
负责根据 SillyTavern 规范将角色卡、世界书、聊天历史等组件
|
||||||
|
拼装成最终的 LLM 消息列表。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage
|
||||||
|
|
||||||
|
from models.internal import CharacterCard, ChatMessage, WorldInfoEntry
|
||||||
|
|
||||||
|
|
||||||
|
class PromptConfig:
|
||||||
|
"""提示词组装配置"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
an_position: str = "after_history", # "before_history" or "after_history"
|
||||||
|
an_depth: int = 4,
|
||||||
|
post_history_instructions: Optional[str] = None
|
||||||
|
):
|
||||||
|
self.an_position = an_position
|
||||||
|
self.an_depth = an_depth
|
||||||
|
self.post_history_instructions = post_history_instructions
|
||||||
|
|
||||||
|
|
||||||
|
class PromptAssembler:
|
||||||
|
"""
|
||||||
|
轻量级提示词组装核心
|
||||||
|
|
||||||
|
不依赖复杂的框架,只负责纯粹的文本拼接和位置插入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SillyTavern 的位置枚举映射
|
||||||
|
POS_WI_BEFORE = 0
|
||||||
|
POS_WI_AFTER = 1
|
||||||
|
POS_EXAMPLES_BEFORE = 2
|
||||||
|
POS_EXAMPLES_AFTER = 3
|
||||||
|
POS_AN_TOP = 4
|
||||||
|
POS_AN_BOTTOM = 5
|
||||||
|
POS_DEPTH = 6
|
||||||
|
POS_OUTLET = 7
|
||||||
|
|
||||||
|
def assemble(
|
||||||
|
self,
|
||||||
|
character: CharacterCard,
|
||||||
|
chat_history: List[ChatMessage],
|
||||||
|
user_input: str,
|
||||||
|
active_entries: List[WorldInfoEntry],
|
||||||
|
config: PromptConfig = PromptConfig()
|
||||||
|
) -> List[BaseMessage]:
|
||||||
|
"""
|
||||||
|
执行完整的提示词组装流程
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[BaseMessage]: 准备好发送给 LLM 的消息列表
|
||||||
|
"""
|
||||||
|
# 1. 按位置分组世界书条目
|
||||||
|
grouped_entries = self._group_entries_by_position(active_entries)
|
||||||
|
|
||||||
|
# 2. 组装 Story String (包含 Pos 0-3)
|
||||||
|
story_string = self._build_story_string(character, grouped_entries)
|
||||||
|
|
||||||
|
# 3. 组装 Author's Note (包含 Pos 4-5)
|
||||||
|
authors_note_content = self._build_authors_note(grouped_entries, config.an_depth)
|
||||||
|
|
||||||
|
# 4. 处理 Chat History 并注入 Depth 条目 (Pos 6)
|
||||||
|
processed_history = self._inject_depth_entries(chat_history, grouped_entries.get(self.POS_DEPTH, []))
|
||||||
|
|
||||||
|
# 5. 准备 Outlet 替换字典 (Pos 7)
|
||||||
|
outlet_map = {entry.uid: entry.content for entry in grouped_entries.get(self.POS_OUTLET, [])}
|
||||||
|
|
||||||
|
# 6. 最终封装为 Messages
|
||||||
|
return self._wrap_to_messages(
|
||||||
|
story_string,
|
||||||
|
authors_note_content,
|
||||||
|
processed_history,
|
||||||
|
user_input,
|
||||||
|
outlet_map,
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
def _group_entries_by_position(self, entries: List[WorldInfoEntry]) -> Dict[int, List[WorldInfoEntry]]:
|
||||||
|
"""将激活的条目按 position 分组"""
|
||||||
|
grouped = {}
|
||||||
|
for entry in entries:
|
||||||
|
# 这里假设 entry.position 存储的是我们定义的 0-7 整数
|
||||||
|
pos = entry.position if isinstance(entry.position, int) else 1 # 默认为 wiAfter
|
||||||
|
if pos not in grouped:
|
||||||
|
grouped[pos] = []
|
||||||
|
grouped[pos].append(entry)
|
||||||
|
|
||||||
|
# 对每个组内的条目按 order 排序
|
||||||
|
for pos in grouped:
|
||||||
|
grouped[pos].sort(key=lambda x: x.order)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
def _build_story_string(self, character: CharacterCard, grouped: Dict) -> str:
|
||||||
|
"""组装故事字符串 (Story String)"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Pos 0: wiBefore
|
||||||
|
for entry in grouped.get(self.POS_WI_BEFORE, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
# 角色核心信息
|
||||||
|
parts.append(f"[Character('{character.name}')]\n{character.description}\n")
|
||||||
|
parts.append(f"Personality: {character.personality}\n")
|
||||||
|
parts.append(f"Scenario: {character.scenario}\n")
|
||||||
|
|
||||||
|
# Pos 1: wiAfter
|
||||||
|
for entry in grouped.get(self.POS_WI_AFTER, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
# Pos 2: Examples Before
|
||||||
|
for entry in grouped.get(self.POS_EXAMPLES_BEFORE, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
# 示例对话
|
||||||
|
if character.mes_example:
|
||||||
|
parts.append(f"<START>\n{character.mes_example}")
|
||||||
|
|
||||||
|
# Pos 3: Examples After
|
||||||
|
for entry in grouped.get(self.POS_EXAMPLES_AFTER, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def _build_authors_note(self, grouped: Dict, depth: int) -> str:
|
||||||
|
"""组装作者笔记 (Author's Note)"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Pos 4: AN Top
|
||||||
|
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||||
|
parts.append(f"[Author's note at depth {depth}]")
|
||||||
|
|
||||||
|
# Pos 5: AN Bottom
|
||||||
|
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
||||||
|
parts.append(entry.content)
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def _inject_depth_entries(self, history: List[ChatMessage], depth_entries: List[WorldInfoEntry]) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
在聊天历史的指定深度插入条目 (Pos 6)
|
||||||
|
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||||
|
"""
|
||||||
|
# 先将历史转换为中间格式
|
||||||
|
msg_list = []
|
||||||
|
for msg in history:
|
||||||
|
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
||||||
|
|
||||||
|
# 按 depth 分组插入
|
||||||
|
# d0 通常指最新用户输入之前,即列表末尾
|
||||||
|
for entry in depth_entries:
|
||||||
|
depth = entry.depth if entry.depth is not None else 0
|
||||||
|
# 计算插入索引 (从后往前数)
|
||||||
|
insert_index = max(0, len(msg_list) - depth)
|
||||||
|
|
||||||
|
# 确定角色
|
||||||
|
role_map = {"system": "system", "user": "user", "assistant": "assistant"}
|
||||||
|
role = role_map.get(str(entry.position).split('_')[-1] if '_' in str(entry.position) else "system", "system")
|
||||||
|
|
||||||
|
msg_list.insert(insert_index, {"role": "system", "content": entry.content})
|
||||||
|
|
||||||
|
return msg_list
|
||||||
|
|
||||||
|
def _replace_outlets(self, text: str, outlet_map: Dict[str, str]) -> str:
|
||||||
|
"""执行 Outlet 宏替换 (Pos 7)"""
|
||||||
|
def replacer(match):
|
||||||
|
uid = match.group(1)
|
||||||
|
return outlet_map.get(uid, "")
|
||||||
|
|
||||||
|
# 匹配 {{outlet::UID}}
|
||||||
|
return re.sub(r"\{\{outlet::([^}]+)\}\}", replacer, text)
|
||||||
|
|
||||||
|
def _wrap_to_messages(
|
||||||
|
self,
|
||||||
|
story_string: str,
|
||||||
|
an_content: str,
|
||||||
|
history: List[Dict],
|
||||||
|
user_input: str,
|
||||||
|
outlet_map: Dict[str, str],
|
||||||
|
config: PromptConfig
|
||||||
|
) -> List[BaseMessage]:
|
||||||
|
"""将组装好的文本块封装为 LangChain Messages"""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# 1. System Message (Story String + Outlet 替换)
|
||||||
|
final_story = self._replace_outlets(story_string, outlet_map)
|
||||||
|
if final_story:
|
||||||
|
messages.append(SystemMessage(content=final_story))
|
||||||
|
|
||||||
|
# 2. Author's Note (根据配置位置插入)
|
||||||
|
if an_content and config.an_position == "before_history":
|
||||||
|
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
|
||||||
|
|
||||||
|
# 3. Chat History
|
||||||
|
for msg_data in history:
|
||||||
|
if msg_data["role"] == "user":
|
||||||
|
messages.append(HumanMessage(content=msg_data["content"]))
|
||||||
|
elif msg_data["role"] == "assistant":
|
||||||
|
messages.append(AIMessage(content=msg_data["content"]))
|
||||||
|
else:
|
||||||
|
messages.append(SystemMessage(content=msg_data["content"]))
|
||||||
|
|
||||||
|
# 4. Author's Note (如果在 History 之后)
|
||||||
|
if an_content and config.an_position == "after_history":
|
||||||
|
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
|
||||||
|
|
||||||
|
# 5. Post-History Instructions & User Input
|
||||||
|
final_input = user_input
|
||||||
|
if config.post_history_instructions:
|
||||||
|
final_input = f"{config.post_history_instructions}\n\n{user_input}"
|
||||||
|
|
||||||
|
messages.append(HumanMessage(content=final_input))
|
||||||
|
|
||||||
|
return messages
|
||||||
410
backend/services/worldbook_service.py
Normal file
410
backend/services/worldbook_service.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
World Book Service
|
||||||
|
世界书服务层 - 处理世界书及条目的 CRUD 操作
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from models.internal import WorldInfo, WorldInfoEntry, ActivationType
|
||||||
|
from models.converters import WorldBookConverter
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class WorldBookService:
|
||||||
|
"""世界书服务类"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_worldbook_path(name: str) -> Path:
|
||||||
|
"""获取世界书文件路径"""
|
||||||
|
return settings.WORLDBOOKS_PATH / f"{name}.json"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_worldbook(name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""加载世界书 JSON 文件"""
|
||||||
|
path = WorldBookService._get_worldbook_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 worldbook '{name}': {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_worldbook(name: str, data: Dict[str, Any]):
|
||||||
|
"""保存世界书到 JSON 文件"""
|
||||||
|
path = WorldBookService._get_worldbook_path(name)
|
||||||
|
try:
|
||||||
|
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 worldbook '{name}': {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_worldbooks() -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取所有世界书的列表(仅基本信息)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
世界书列表,每个包含 name, description, entries_count 等
|
||||||
|
"""
|
||||||
|
worldbooks = []
|
||||||
|
|
||||||
|
for json_file in settings.WORLDBOOKS_PATH.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# 内部格式:entries 是列表
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
entries_count = len(entries) if isinstance(entries, list) else 0
|
||||||
|
|
||||||
|
worldbooks.append({
|
||||||
|
"name": data.get("name", json_file.stem),
|
||||||
|
"description": data.get("description", ""),
|
||||||
|
"entries_count": entries_count,
|
||||||
|
"createdAt": data.get("createdAt", 0),
|
||||||
|
"updatedAt": data.get("updatedAt", 0)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading worldbook {json_file.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 按更新时间排序
|
||||||
|
worldbooks.sort(key=lambda x: x.get("updatedAt", 0), reverse=True)
|
||||||
|
return worldbooks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_worldbook(name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取指定世界书的完整数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
世界书完整数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_worldbook(name: str, description: str = "") -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
创建新世界书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
description: 世界书描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的世界书数据
|
||||||
|
"""
|
||||||
|
# 检查是否已存在
|
||||||
|
if WorldBookService._get_worldbook_path(name).exists():
|
||||||
|
raise ValueError(f"Worldbook '{name}' already exists")
|
||||||
|
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
worldbook_data = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"entries": [],
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now,
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldBookService._save_worldbook(name, worldbook_data)
|
||||||
|
return worldbook_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_worldbook(name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
更新世界书基本信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
description: 新的描述(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的世界书数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
if description is not None:
|
||||||
|
data["description"] = description
|
||||||
|
|
||||||
|
data["updatedAt"] = int(datetime.now().timestamp())
|
||||||
|
WorldBookService._save_worldbook(name, data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_worldbook(name: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除世界书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否删除成功
|
||||||
|
"""
|
||||||
|
path = WorldBookService._get_worldbook_path(name)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
path.unlink()
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取世界书的条目列表(支持分页)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
page: 页码,从1开始
|
||||||
|
page_size: 每页数量,默认20
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含条目列表和分页信息的字典
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
# 内部格式:entries 是列表
|
||||||
|
all_entries = data.get("entries", [])
|
||||||
|
if not isinstance(all_entries, list):
|
||||||
|
all_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 # 向上取整
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_entry(name: str, uid: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取世界书的指定条目
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
uid: 条目 UID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
条目数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
# 内部格式:entries 是列表
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
|
||||||
|
return entry
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
在世界书中创建新条目
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
entry_data: 条目数据(不包含 uid, createdAt, updatedAt)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的条目数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
# 生成 UID 和时间戳
|
||||||
|
now = int(datetime.now().timestamp())
|
||||||
|
new_entry = {
|
||||||
|
"uid": str(uuid.uuid4()),
|
||||||
|
"key": entry_data.get("key", []),
|
||||||
|
"keysecondary": entry_data.get("keysecondary", []),
|
||||||
|
"content": entry_data.get("content", ""),
|
||||||
|
"activationType": entry_data.get("activationType", ActivationType.KEYWORD.value),
|
||||||
|
"logicExpression": entry_data.get("logicExpression"),
|
||||||
|
"ragConfig": entry_data.get("ragConfig"),
|
||||||
|
"order": entry_data.get("order", 0),
|
||||||
|
"position": entry_data.get("position", "after_char"),
|
||||||
|
"depth": entry_data.get("depth"),
|
||||||
|
"probability": entry_data.get("probability", 100),
|
||||||
|
"group": entry_data.get("group", []),
|
||||||
|
"disable": entry_data.get("disable", False),
|
||||||
|
"createdAt": now,
|
||||||
|
"updatedAt": now
|
||||||
|
}
|
||||||
|
|
||||||
|
data["entries"].append(new_entry)
|
||||||
|
data["updatedAt"] = now
|
||||||
|
WorldBookService._save_worldbook(name, data)
|
||||||
|
|
||||||
|
return new_entry
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_entry(name: str, uid: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
更新世界书的指定条目
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
uid: 条目 UID
|
||||||
|
entry_data: 更新的字段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的条目数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
for i, entry in enumerate(data.get("entries", [])):
|
||||||
|
if entry.get("uid") == uid:
|
||||||
|
# 更新字段
|
||||||
|
for key, value in entry_data.items():
|
||||||
|
if key not in ["uid", "createdAt"]: # 不修改 UID 和创建时间
|
||||||
|
entry[key] = value
|
||||||
|
|
||||||
|
# 更新时间戳
|
||||||
|
entry["updatedAt"] = int(datetime.now().timestamp())
|
||||||
|
data["entries"][i] = entry
|
||||||
|
data["updatedAt"] = entry["updatedAt"]
|
||||||
|
|
||||||
|
WorldBookService._save_worldbook(name, data)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_entry(name: str, uid: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除世界书的指定条目
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
uid: 条目 UID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否删除成功
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
original_length = len(data.get("entries", []))
|
||||||
|
data["entries"] = [e for e in data.get("entries", []) if e.get("uid") != uid]
|
||||||
|
|
||||||
|
if len(data["entries"]) == original_length:
|
||||||
|
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||||
|
|
||||||
|
data["updatedAt"] = int(datetime.now().timestamp())
|
||||||
|
WorldBookService._save_worldbook(name, data)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def import_from_sillytavern(name: str, st_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从 SillyTavern 格式导入世界书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
st_data: SillyTavern 格式的世界书数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转换后的内部格式世界书数据
|
||||||
|
"""
|
||||||
|
# 使用转换器进行转换
|
||||||
|
worldbook_data = WorldBookConverter.st_to_internal(st_data, name)
|
||||||
|
|
||||||
|
# 保存到文件
|
||||||
|
WorldBookService._save_worldbook(name, worldbook_data)
|
||||||
|
|
||||||
|
return worldbook_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def import_internal_format(name: str, internal_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
直接导入内部格式的世界书(无需转换)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
internal_data: 内部格式的世界书数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
内部格式世界书数据
|
||||||
|
"""
|
||||||
|
# 确保包含必要的字段
|
||||||
|
if "name" not in internal_data:
|
||||||
|
internal_data["name"] = name
|
||||||
|
|
||||||
|
# 规范化所有条目,确保有 trigger_config
|
||||||
|
if "entries" in internal_data and isinstance(internal_data["entries"], list):
|
||||||
|
normalized_entries = []
|
||||||
|
for entry in internal_data["entries"]:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
normalized_entry = WorldBookConverter.normalize_entry(entry)
|
||||||
|
normalized_entries.append(normalized_entry)
|
||||||
|
internal_data["entries"] = normalized_entries
|
||||||
|
|
||||||
|
# 保存文件
|
||||||
|
WorldBookService._save_worldbook(name, internal_data)
|
||||||
|
|
||||||
|
return internal_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_to_sillytavern(name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
导出为 SillyTavern 格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SillyTavern 格式的世界书数据
|
||||||
|
"""
|
||||||
|
data = WorldBookService._load_worldbook(name)
|
||||||
|
if not data:
|
||||||
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
|
# 使用转换器进行转换
|
||||||
|
st_data = WorldBookConverter.internal_to_st(data)
|
||||||
|
|
||||||
|
return st_data
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
worldbook_service = WorldBookService()
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from ..core import config
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
# 使用配置中的 DATA_PATH 并添加 "chat" 子目录
|
|
||||||
ROOT_DIR = config.settings.DATA_PATH / "chat"
|
|
||||||
|
|
||||||
def get_all_role_and_chat() -> Dict[str, List[str]]:
|
|
||||||
"""
|
|
||||||
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
|
|
||||||
|
|
||||||
返回:
|
|
||||||
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
# 确保目标目录存在
|
|
||||||
if not ROOT_DIR.exists():
|
|
||||||
print(f"警告: 目录 {ROOT_DIR} 不存在")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# 打印根目录路径和内容(调试用)
|
|
||||||
print(f"正在扫描目录: {ROOT_DIR}")
|
|
||||||
print(f"根目录内容: {list(ROOT_DIR.iterdir())}")
|
|
||||||
|
|
||||||
# 遍历根目录下的所有条目
|
|
||||||
for entry in ROOT_DIR.iterdir():
|
|
||||||
try:
|
|
||||||
# 只处理文件夹
|
|
||||||
if entry.is_dir():
|
|
||||||
print(f"处理文件夹: {entry.name}") # 调试信息
|
|
||||||
jsonl_files = []
|
|
||||||
|
|
||||||
# 遍历子文件夹中的所有文件
|
|
||||||
for file in entry.iterdir():
|
|
||||||
if file.is_file() and file.suffix == '.jsonl':
|
|
||||||
jsonl_files.append(str(file))
|
|
||||||
print(f" 找到文件: {file.name}") # 调试信息
|
|
||||||
|
|
||||||
# 如果该文件夹中有 JSONL 文件,则添加到结果中
|
|
||||||
if jsonl_files:
|
|
||||||
result[entry.name] = jsonl_files
|
|
||||||
except Exception as e:
|
|
||||||
print(f"处理文件夹 {entry.name} 时出错: {str(e)}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import json
|
|
||||||
from datetime import datetime
|
|
||||||
from backend.core import config as cfg
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# 假设 ChatRequest 定义在这里或者从其他地方导入
|
|
||||||
# from backend.app.core.items import ChatRequest
|
|
||||||
|
|
||||||
async def save_input_to_json(chat_request: ChatRequest):
|
|
||||||
"""
|
|
||||||
保存消息到JSONL文件或处理重roll请求
|
|
||||||
|
|
||||||
参数:
|
|
||||||
chat_request: 包含消息详情的请求对象
|
|
||||||
"""
|
|
||||||
# 1. 从对象中提取属性
|
|
||||||
mes = chat_request.mes
|
|
||||||
role_name = chat_request.role_name
|
|
||||||
chat_name = chat_request.chat_name
|
|
||||||
name = chat_request.name
|
|
||||||
is_user = chat_request.is_user
|
|
||||||
floor_number = chat_request.floor_number
|
|
||||||
# stream, img_switch, table_switch 等虽然在这个函数逻辑中没用到,
|
|
||||||
# 但如果 ChatRequest 中有,也可以提取出来备用
|
|
||||||
# stream = chat_request.stream
|
|
||||||
# ...
|
|
||||||
|
|
||||||
config = cfg.settings
|
|
||||||
# 注意:这里要确保 role_name 和 chat_name 不为 None,否则路径拼接会报错
|
|
||||||
# 建议在函数入口处增加校验,或者在 Pydantic 模型中设置为必填项
|
|
||||||
if not role_name or not chat_name:
|
|
||||||
raise ValueError("role_name and chat_name cannot be empty")
|
|
||||||
|
|
||||||
file_path = config.BASE_PATH / "data" / "chat" / role_name / f"{chat_name}.jsonl"
|
|
||||||
|
|
||||||
# 确保目录存在
|
|
||||||
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 读取文件内容
|
|
||||||
try:
|
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
except FileNotFoundError:
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# 判断是否为重roll请求
|
|
||||||
is_regenerate = False
|
|
||||||
target_index = -1
|
|
||||||
|
|
||||||
if lines and floor_number > 0:
|
|
||||||
# 计算当前楼层号
|
|
||||||
current_floor = len(lines)
|
|
||||||
|
|
||||||
# 如果floor_number与当前楼层号相同,则为重roll请求
|
|
||||||
if floor_number == current_floor:
|
|
||||||
# 找到最后一条非用户消息
|
|
||||||
for i in range(len(lines) - 1, -1, -1):
|
|
||||||
try:
|
|
||||||
line_data = json.loads(lines[i])
|
|
||||||
if not line_data.get('is_user', False):
|
|
||||||
is_regenerate = True
|
|
||||||
target_index = i
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 处理重roll逻辑
|
|
||||||
if is_regenerate:
|
|
||||||
# 解析目标消息
|
|
||||||
try:
|
|
||||||
target_message = json.loads(lines[target_index])
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise ValueError(f"无法解析楼层 {floor_number} 的JSON数据")
|
|
||||||
|
|
||||||
# 初始化swipes数组
|
|
||||||
if target_message.get('swipes') is None:
|
|
||||||
target_message['swipes'] = []
|
|
||||||
|
|
||||||
# 将新回复添加到swipes数组
|
|
||||||
target_message['swipes'].append(mes)
|
|
||||||
|
|
||||||
# 更新swipe_id和content
|
|
||||||
target_message['swipes_id'] = len(target_message['swipes']) - 1
|
|
||||||
target_message['content'] = mes
|
|
||||||
|
|
||||||
# 更新文件内容
|
|
||||||
lines[target_index] = json.dumps(target_message, ensure_ascii=False) + '\n'
|
|
||||||
|
|
||||||
# 写回文件
|
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
|
||||||
f.writelines(lines)
|
|
||||||
|
|
||||||
return target_message
|
|
||||||
|
|
||||||
# 处理普通消息保存逻辑
|
|
||||||
else:
|
|
||||||
# 获取当前时间
|
|
||||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
# 构建消息对象
|
|
||||||
message = {
|
|
||||||
"role": role_name,
|
|
||||||
"chat": chat_name,
|
|
||||||
"content": mes,
|
|
||||||
"name": name,
|
|
||||||
"is_user": is_user,
|
|
||||||
"send_date": current_time,
|
|
||||||
"floor_number": len(lines) + 1, # 记录楼层号
|
|
||||||
"swipes": [],
|
|
||||||
"swipes_id": 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# 追加到文件
|
|
||||||
with open(file_path, 'a', encoding='utf-8') as f:
|
|
||||||
f.write(json.dumps(message, ensure_ascii=False) + '\n')
|
|
||||||
|
|
||||||
return message
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# 注意:为了在本地运行测试,你需要手动构造一个 ChatRequest 对象
|
|
||||||
# 或者临时修改函数签名以便直接传参测试
|
|
||||||
|
|
||||||
# 示例:假设 ChatRequest 是一个简单的类或 Pydantic 模型
|
|
||||||
class MockChatRequest:
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self.mes = kwargs.get('mes')
|
|
||||||
self.role_name = kwargs.get('role_name')
|
|
||||||
self.chat_name = kwargs.get('chat_name')
|
|
||||||
self.name = kwargs.get('name')
|
|
||||||
self.is_user = kwargs.get('is_user')
|
|
||||||
self.floor_number = kwargs.get('floor_number')
|
|
||||||
|
|
||||||
|
|
||||||
# 测试重roll最后一条AI消息
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
|
|
||||||
async def test():
|
|
||||||
req = MockChatRequest(
|
|
||||||
mes="这是重roll后的新回复2",
|
|
||||||
role_name="test",
|
|
||||||
chat_name="111",
|
|
||||||
name="AI",
|
|
||||||
is_user=False,
|
|
||||||
floor_number=2
|
|
||||||
)
|
|
||||||
await save_input_to_json(req)
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(test())
|
|
||||||
17
backend/utils/__init__.py
Normal file
17
backend/utils/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
工具类包
|
||||||
|
|
||||||
|
提供通用的工具函数和辅助类,如文件操作、LLM 调用封装等。
|
||||||
|
"""
|
||||||
|
from .file_utils import get_all_roles_and_chats, read_jsonl_file, write_jsonl_file
|
||||||
|
from .llm_client import get_llm, get_fast_llm, get_creative_llm, get_streaming_llm
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_all_roles_and_chats',
|
||||||
|
'read_jsonl_file',
|
||||||
|
'write_jsonl_file',
|
||||||
|
'get_llm',
|
||||||
|
'get_fast_llm',
|
||||||
|
'get_creative_llm',
|
||||||
|
'get_streaming_llm',
|
||||||
|
]
|
||||||
130
backend/utils/file_utils.py
Normal file
130
backend/utils/file_utils.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
文件操作工具函数
|
||||||
|
|
||||||
|
提供文件和目录操作的通用工具
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_roles_and_chats(data_path: Path) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
获取所有角色和聊天列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_path: 数据目录路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, List[str]]: 字典结构,键是角色名称,值是该角色的聊天列表
|
||||||
|
"""
|
||||||
|
chat_dir = data_path / "chat"
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if not chat_dir.exists():
|
||||||
|
logger.warning(f"聊天目录不存在: {chat_dir}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
for entry in chat_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if entry.is_dir():
|
||||||
|
jsonl_files = []
|
||||||
|
|
||||||
|
for file in entry.iterdir():
|
||||||
|
if file.is_file() and file.suffix == '.jsonl':
|
||||||
|
jsonl_files.append(file.stem)
|
||||||
|
|
||||||
|
if jsonl_files:
|
||||||
|
result[entry.name] = jsonl_files
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理文件夹 {entry.name} 时出错: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_directory_exists(path: Path) -> None:
|
||||||
|
"""
|
||||||
|
确保目录存在,如果不存在则创建
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: 目录路径
|
||||||
|
"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(file_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
读取 JSON 文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: JSON 数据
|
||||||
|
"""
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(file_path: Path, data: dict) -> None:
|
||||||
|
"""
|
||||||
|
写入 JSON 文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
data: 要写入的数据
|
||||||
|
"""
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def read_jsonl_file(file_path: Path) -> List[dict]:
|
||||||
|
"""
|
||||||
|
读取 JSONL 文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[dict]: JSONL 数据列表
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
lines.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"解析 JSONL 行失败: {e}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def append_to_jsonl_file(file_path: Path, data: dict) -> None:
|
||||||
|
"""
|
||||||
|
追加数据到 JSONL 文件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
data: 要追加的数据
|
||||||
|
"""
|
||||||
|
with open(file_path, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(json.dumps(data, ensure_ascii=False) + '\n')
|
||||||
|
|
||||||
|
|
||||||
|
def write_jsonl_file(file_path: Path, data_list: List[dict]) -> None:
|
||||||
|
"""
|
||||||
|
写入 JSONL 文件 (覆盖模式)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 文件路径
|
||||||
|
data_list: 数据列表
|
||||||
|
"""
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
for data in data_list:
|
||||||
|
f.write(json.dumps(data, ensure_ascii=False) + '\n')
|
||||||
88
backend/utils/llm_client.py
Normal file
88
backend/utils/llm_client.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
LLM 客户端工具
|
||||||
|
|
||||||
|
提供统一的 LLM 接口,支持多种模型提供商。
|
||||||
|
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from langchain_core.language_models.chat_models import BaseChatModel
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm(
|
||||||
|
provider: str = "openai",
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
streaming: bool = False,
|
||||||
|
**kwargs
|
||||||
|
) -> BaseChatModel:
|
||||||
|
"""
|
||||||
|
获取 LLM 实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: 模型提供商 ("openai", "anthropic", "ollama")
|
||||||
|
model: 模型名称 (如果不指定则使用配置中的默认值)
|
||||||
|
temperature: 温度参数 (0-2)
|
||||||
|
streaming: 是否启用流式输出
|
||||||
|
**kwargs: 其他参数传递给模型
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BaseChatModel: LangChain 的聊天模型实例
|
||||||
|
"""
|
||||||
|
|
||||||
|
if provider == "openai":
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=model or settings.OPENAI_MODEL or "gpt-4",
|
||||||
|
temperature=temperature,
|
||||||
|
api_key=settings.OPENAI_API_KEY,
|
||||||
|
streaming=streaming,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
elif provider == "anthropic":
|
||||||
|
from langchain_anthropic import ChatAnthropic
|
||||||
|
|
||||||
|
return ChatAnthropic(
|
||||||
|
model=model or settings.ANTHROPIC_MODEL or "claude-3-opus-20240229",
|
||||||
|
temperature=temperature,
|
||||||
|
api_key=settings.ANTHROPIC_API_KEY,
|
||||||
|
max_tokens=kwargs.pop("max_tokens", 4096),
|
||||||
|
streaming=streaming,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
elif provider == "ollama":
|
||||||
|
try:
|
||||||
|
from langchain_ollama import ChatOllama
|
||||||
|
|
||||||
|
return ChatOllama(
|
||||||
|
model=model or settings.OLLAMA_MODEL or "llama3",
|
||||||
|
base_url=settings.OLLAMA_BASE_URL or "http://localhost:11434",
|
||||||
|
temperature=temperature,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"langchain-ollama not installed. Run: pip install langchain-ollama"
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported provider: {provider}. Use 'openai', 'anthropic', or 'ollama'")
|
||||||
|
|
||||||
|
|
||||||
|
# 便捷函数 - 常用配置
|
||||||
|
def get_fast_llm(provider: str = "openai") -> BaseChatModel:
|
||||||
|
"""获取快速响应的 LLM (低温度,适合事实性问题)"""
|
||||||
|
return get_llm(provider, temperature=0.3)
|
||||||
|
|
||||||
|
|
||||||
|
def get_creative_llm(provider: str = "openai") -> BaseChatModel:
|
||||||
|
"""获取创造性 LLM (高温度,适合创意写作)"""
|
||||||
|
return get_llm(provider, temperature=0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
|
||||||
|
"""获取支持流式输出的 LLM"""
|
||||||
|
return get_llm(provider, streaming=True)
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
# backend/app/workflows/llm_workflow.py
|
|
||||||
from typing import Dict, Any, List, Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowStatus(Enum):
|
|
||||||
"""工作流状态枚举"""
|
|
||||||
INITIALIZED = "initialized"
|
|
||||||
RUNNING = "running"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
PAUSED = "paused"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class WorkflowContext:
|
|
||||||
"""工作流上下文"""
|
|
||||||
data: Dict[str, Any]
|
|
||||||
status: WorkflowStatus = WorkflowStatus.INITIALIZED
|
|
||||||
metadata: Dict[str, Any] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.metadata is None:
|
|
||||||
self.metadata = {}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowNode:
|
|
||||||
"""工作流节点声明"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
handler: Callable,
|
|
||||||
enabled: bool = True,
|
|
||||||
config: Dict[str, Any] = None
|
|
||||||
):
|
|
||||||
self.name = name # 节点的唯一标识符,用于区分不同的节点。
|
|
||||||
self.handler = handler # 一个可调用对象(函数或方法),这是节点实际执行的处理逻辑。
|
|
||||||
self.enabled = enabled # 布尔值,控制节点是否启用。默认为 True,如果设置为 False,节点将被跳过。
|
|
||||||
self.config = config or {} # 一个字典,用于存储节点的配置信息。默认为空字典。
|
|
||||||
self.next_nodes: List['WorkflowNode'] = [] # 一个节点列表,用于指定当前节点执行完成后应跳转到的下一个节点。默认为空列表,可能指向多分支。
|
|
||||||
|
|
||||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
|
||||||
"""执行节点处理"""
|
|
||||||
if not self.enabled:
|
|
||||||
return context
|
|
||||||
|
|
||||||
try:
|
|
||||||
context = self.handler(context, self.config)
|
|
||||||
return context
|
|
||||||
except Exception as e:
|
|
||||||
context.status = WorkflowStatus.FAILED
|
|
||||||
context.metadata["error"] = str(e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
class LLMWorkflow:
|
|
||||||
"""LLM工作流声明"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.nodes: List[WorkflowNode] = []
|
|
||||||
self._initialize_workflow()
|
|
||||||
|
|
||||||
def _initialize_workflow(self):
|
|
||||||
"""初始化工作流节点(仅声明,不实现)"""
|
|
||||||
# 输入节点
|
|
||||||
input_node = WorkflowNode(
|
|
||||||
name="input",
|
|
||||||
handler=self._input_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 输入预处理节点(可开关)
|
|
||||||
preprocessing_node = WorkflowNode(
|
|
||||||
name="preprocessing",
|
|
||||||
handler=self._preprocessing_handler,
|
|
||||||
enabled=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# RAG处理节点
|
|
||||||
rag_node = WorkflowNode(
|
|
||||||
name="rag",
|
|
||||||
handler=self._rag_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 提示词组装节点
|
|
||||||
prompt_assembly_node = WorkflowNode(
|
|
||||||
name="prompt_assembly",
|
|
||||||
handler=self._prompt_assembly_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# LLM请求节点
|
|
||||||
llm_request_node = WorkflowNode(
|
|
||||||
name="llm_request",
|
|
||||||
handler=self._llm_request_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 图像生成节点(可开关)
|
|
||||||
image_generation_node = WorkflowNode(
|
|
||||||
name="image_generation",
|
|
||||||
handler=self._image_generation_handler,
|
|
||||||
enabled=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# 动态表格更新节点(可开关)
|
|
||||||
dynamic_table_node = WorkflowNode(
|
|
||||||
name="dynamic_table",
|
|
||||||
handler=self._dynamic_table_handler,
|
|
||||||
enabled=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# 输出过滤节点
|
|
||||||
output_filter_node = WorkflowNode(
|
|
||||||
name="output_filter",
|
|
||||||
handler=self._output_filter_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 输出节点
|
|
||||||
output_node = WorkflowNode(
|
|
||||||
name="output",
|
|
||||||
handler=self._output_handler
|
|
||||||
)
|
|
||||||
|
|
||||||
# 设置节点顺序(构建工作流)
|
|
||||||
self.nodes = [
|
|
||||||
input_node,
|
|
||||||
preprocessing_node,
|
|
||||||
rag_node,
|
|
||||||
prompt_assembly_node,
|
|
||||||
llm_request_node,
|
|
||||||
image_generation_node,
|
|
||||||
dynamic_table_node,
|
|
||||||
output_filter_node,
|
|
||||||
output_node
|
|
||||||
]
|
|
||||||
|
|
||||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
|
||||||
"""执行工作流"""
|
|
||||||
context.status = WorkflowStatus.RUNNING
|
|
||||||
|
|
||||||
for node in self.nodes:
|
|
||||||
try:
|
|
||||||
context = node.execute(context)
|
|
||||||
|
|
||||||
# 如果工作流失败,停止执行
|
|
||||||
if context.status == WorkflowStatus.FAILED:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
context.status = WorkflowStatus.FAILED
|
|
||||||
context.metadata["error"] = str(e)
|
|
||||||
break
|
|
||||||
|
|
||||||
if context.status != WorkflowStatus.FAILED:
|
|
||||||
context.status = WorkflowStatus.COMPLETED
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def enable_node(self, node_name: str, enabled: bool = True):
|
|
||||||
"""启用或禁用特定节点"""
|
|
||||||
for node in self.nodes:
|
|
||||||
if node.name == node_name:
|
|
||||||
node.enabled = enabled
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 以下是节点处理函数声明(仅声明,不实现)
|
|
||||||
def _input_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""输入节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _preprocessing_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""输入预处理节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _rag_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""RAG处理节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _prompt_assembly_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""提示词组装节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _llm_request_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""LLM请求节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _image_generation_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""图像生成节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _dynamic_table_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""动态表格更新节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _output_filter_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""输出过滤节点处理函数"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _output_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
|
||||||
"""输出节点处理函数"""
|
|
||||||
pass
|
|
||||||
201
check_frontend_api.py
Normal file
201
check_frontend_api.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
检查前端世界书功能与后端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)
|
||||||
49
check_worldbook_path.py
Normal file
49
check_worldbook_path.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""
|
||||||
|
检查世界书路径和文件
|
||||||
|
"""
|
||||||
|
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)
|
||||||
53
clear_default_avatar.py
Normal file
53
clear_default_avatar.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
清空默认头像图片中的嵌入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)
|
||||||
87
convert_worldbooks.py
Normal file
87
convert_worldbooks.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
将现有的 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 后缀,确认无误后可删除")
|
||||||
BIN
data/characters/defult.png
Normal file
BIN
data/characters/defult.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
23
data/characters/测试角色1/character.json
Normal file
23
data/characters/测试角色1/character.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
4
data/characters/测试角色1/chats/默认聊天.jsonl
Normal file
4
data/characters/测试角色1/chats/默认聊天.jsonl
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{"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": "我很好,谢谢关心!你呢?"}
|
||||||
20
data/characters/测试角色2/character.json
Normal file
20
data/characters/测试角色2/character.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
4
data/characters/测试角色2/chats/工作讨论.jsonl
Normal file
4
data/characters/测试角色2/chats/工作讨论.jsonl
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{"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": "好的,请提供数据,我会进行专业分析。"}
|
||||||
2
data/characters/测试角色2/chats/默认聊天.jsonl
Normal file
2
data/characters/测试角色2/chats/默认聊天.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"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}
|
||||||
22
data/characters/测试角色3/character.json
Normal file
22
data/characters/测试角色3/character.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
2
data/characters/测试角色3/chats/默认聊天.jsonl
Normal file
2
data/characters/测试角色3/chats/默认聊天.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"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}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
{"role": "test", "chat": "111", "content": "你好", "name": "用户", "is_user": true, "send_date": "2026-03-12 18:26:50", "floor_number": 1, "swipes": [], "swipes_id": 0}
|
|
||||||
{"role": "test", "chat": "111", "content": "这是重roll后的新回复2", "name": "AI", "is_user": false, "send_date": "2026-03-12 18:26:50", "floor_number": 2, "swipes": ["这是重roll后的新回复", "这是重roll后的新回复2"], "swipes_id": 1}
|
|
||||||
16
data/imports/导入测试角色.json
Normal file
16
data/imports/导入测试角色.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "导入测试角色",
|
||||||
|
"description": "这是一个用于测试导入功能的角色",
|
||||||
|
"personality": "温和、耐心、善于倾听",
|
||||||
|
"scenario": "心理咨询场景",
|
||||||
|
"first_mes": "你好,我是你的倾听者。有什么想和我分享的吗?",
|
||||||
|
"mes_example": "",
|
||||||
|
"categories": ["测试", "心理"],
|
||||||
|
"tags": ["import-test", "counselor", "listener"],
|
||||||
|
"worldInfoId": null,
|
||||||
|
"outputSchema": null,
|
||||||
|
"alternate_greetings": [
|
||||||
|
"欢迎到来,我在这里听你说。"
|
||||||
|
],
|
||||||
|
"isFavorite": false
|
||||||
|
}
|
||||||
218
data/preset/Default.json
Normal file
218
data/preset/Default.json
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"temperature": 1,
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"top_p": 1,
|
||||||
|
"top_k": 0,
|
||||||
|
"top_a": 0,
|
||||||
|
"min_p": 0,
|
||||||
|
"repetition_penalty": 1,
|
||||||
|
"openai_max_context": 4095,
|
||||||
|
"openai_max_tokens": 300,
|
||||||
|
"names_behavior": 0,
|
||||||
|
"send_if_empty": "",
|
||||||
|
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
|
||||||
|
"new_chat_prompt": "[Start a new Chat]",
|
||||||
|
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
|
||||||
|
"new_example_chat_prompt": "[Example Chat]",
|
||||||
|
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
|
||||||
|
"bias_preset_selected": "Default (none)",
|
||||||
|
"max_context_unlocked": false,
|
||||||
|
"wi_format": "{0}",
|
||||||
|
"scenario_format": "{{scenario}}",
|
||||||
|
"personality_format": "{{personality}}",
|
||||||
|
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
|
||||||
|
"stream_openai": true,
|
||||||
|
"prompts": [
|
||||||
|
{
|
||||||
|
"name": "Main Prompt",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
||||||
|
"identifier": "main"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Auxiliary Prompt",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "",
|
||||||
|
"identifier": "nsfw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "dialogueExamples",
|
||||||
|
"name": "Chat Examples",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Post-History Instructions",
|
||||||
|
"system_prompt": true,
|
||||||
|
"role": "system",
|
||||||
|
"content": "",
|
||||||
|
"identifier": "jailbreak"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "chatHistory",
|
||||||
|
"name": "Chat History",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoAfter",
|
||||||
|
"name": "World Info (after)",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoBefore",
|
||||||
|
"name": "World Info (before)",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "enhanceDefinitions",
|
||||||
|
"role": "system",
|
||||||
|
"name": "Enhance Definitions",
|
||||||
|
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charDescription",
|
||||||
|
"name": "Char Description",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charPersonality",
|
||||||
|
"name": "Char Personality",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "scenario",
|
||||||
|
"name": "Scenario",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "personaDescription",
|
||||||
|
"name": "Persona Description",
|
||||||
|
"system_prompt": true,
|
||||||
|
"marker": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"prompt_order": [
|
||||||
|
{
|
||||||
|
"character_id": 100000,
|
||||||
|
"order": [
|
||||||
|
{
|
||||||
|
"identifier": "main",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoBefore",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charDescription",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charPersonality",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "scenario",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "enhanceDefinitions",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "nsfw",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoAfter",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "dialogueExamples",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "chatHistory",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "jailbreak",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"character_id": 100001,
|
||||||
|
"order": [
|
||||||
|
{
|
||||||
|
"identifier": "main",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoBefore",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "personaDescription",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charDescription",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "charPersonality",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "scenario",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "enhanceDefinitions",
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "nsfw",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "worldInfoAfter",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "dialogueExamples",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "chatHistory",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "jailbreak",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assistant_prefill": "",
|
||||||
|
"assistant_impersonation": "",
|
||||||
|
"use_sysprompt": false,
|
||||||
|
"squash_system_messages": false,
|
||||||
|
"media_inlining": true,
|
||||||
|
"continue_prefill": false,
|
||||||
|
"continue_postfix": " ",
|
||||||
|
"seed": -1,
|
||||||
|
"n": 1
|
||||||
|
}
|
||||||
674
data/worldbooks/卡立创-v5.json
Normal file
674
data/worldbooks/卡立创-v5.json
Normal file
File diff suppressed because one or more lines are too long
@@ -2,28 +2,56 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
command: uvicorn backend.api.route:app --host 0.0.0.0 --port 8000 --reload
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: llm-backend
|
||||||
|
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
ports:
|
ports:
|
||||||
- "3001:8000"
|
- "23337:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- ./backend:/app
|
||||||
- ./data:/data
|
- ./data:/app/data
|
||||||
- ./outputs:/outputs
|
- ./outputs:/app/outputs
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build:
|
||||||
command: streamlit run app.py --server.port=8501 --server.address=0.0.0.0 --server.fileWatcherType=poll
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: development
|
||||||
|
container_name: llm-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:8501"
|
- "23338:5173"
|
||||||
environment:
|
|
||||||
- BACKEND_URL=http://backend:8000
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app # 确保宿主机路径与容器内路径一致
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- VITE_API_URL=http://backend:8000
|
||||||
|
- VITE_WS_URL=ws://backend:8000
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- llm-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
llm-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
|
|||||||
3
frontend/.env.development
Normal file
3
frontend/.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
VITE_API_URL=http://localhost:23337/api
|
||||||
|
VITE_WS_URL=ws://localhost:23337/api
|
||||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 环境变量示例
|
||||||
|
VITE_API_URL=http://localhost:23337/api
|
||||||
|
VITE_WS_URL=ws://localhost:23337/api
|
||||||
3
frontend/.env.production
Normal file
3
frontend/.env.production
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
VITE_API_URL=/api
|
||||||
|
VITE_WS_URL=ws://backend:8000/api
|
||||||
BIN
frontend/COMPONENT_STRUCTURE.txt
Normal file
BIN
frontend/COMPONENT_STRUCTURE.txt
Normal file
Binary file not shown.
@@ -1,18 +1,62 @@
|
|||||||
# 使用 Python 3.11 基础镜像
|
# 多阶段构建 - 开发环境
|
||||||
FROM python:3.11-slim
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制依赖文件并安装
|
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
|
||||||
COPY requirements.txt .
|
RUN npm config set registry https://registry.npmmirror.com/
|
||||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
|
||||||
|
|
||||||
# 复制所有代码
|
# 复制 package.json 和 package-lock.json
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 复制源代码到容器
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露 Vite 默认端口 5173
|
||||||
EXPOSE 8501
|
EXPOSE 5173
|
||||||
|
|
||||||
# 启动命令(使用 8501 端口,与 docker-compose 映射保持一致)
|
# 设置环境变量
|
||||||
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
ENV NODE_ENV=development
|
||||||
|
ENV VITE_API_URL=http://backend:8000/api
|
||||||
|
|
||||||
|
# 启动 Vite 开发服务器
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
|
# 多阶段构建 - 生产环境
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置 npm 镜像源
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com/
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建生产版本
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 生产环境镜像
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
# 复制构建产物到 Nginx
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 Nginx 配置文件
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# 启动 Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
608
frontend/app.py
608
frontend/app.py
@@ -1,608 +0,0 @@
|
|||||||
import requests
|
|
||||||
import streamlit as st
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
|
|
||||||
# --- 页面配置 ---
|
|
||||||
st.set_page_config(
|
|
||||||
page_title="AI WorkFlow Engine",
|
|
||||||
page_icon="🤖",
|
|
||||||
layout="wide",
|
|
||||||
initial_sidebar_state="expanded"
|
|
||||||
)
|
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000")
|
|
||||||
print(f"DEBUG BACKEND_URL: {BACKEND_URL}", flush=True) # 看 docker logs
|
|
||||||
|
|
||||||
# --- 自定义 CSS (蓝白清晰风格) ---
|
|
||||||
st.markdown("""
|
|
||||||
<style>
|
|
||||||
/* 全局背景与字体 */
|
|
||||||
.stApp {
|
|
||||||
background-color: #F0F4F8; /* 浅蓝灰背景,护眼且清晰 */
|
|
||||||
color: #1A1A1A; /* 深黑色字体 */
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏默认菜单 */
|
|
||||||
#MainMenu {visibility: hidden;}
|
|
||||||
footer {visibility: hidden;}
|
|
||||||
header {visibility: hidden;} /* 隐藏顶部默认栏,使用自定义工具栏 */
|
|
||||||
|
|
||||||
/* 侧边栏样式 */
|
|
||||||
section[data-testid="stSidebar"] {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
border-right: 1px solid #D1D9E6;
|
|
||||||
color: #1A1A1A;
|
|
||||||
}
|
|
||||||
section[data-testid="stSidebar"] .stMarkdown,
|
|
||||||
section[data-testid="stSidebar"] .stNumberInput,
|
|
||||||
section[data-testid="stSidebar"] .stSlider {
|
|
||||||
color: #1A1A1A;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 聊天容器背景 (白色卡片感) */
|
|
||||||
.stChatMessage {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
border: 1px solid #E1E8F0;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 用户消息特殊样式 */
|
|
||||||
.stChatMessage[data-testid="stChatMessage"]:has(.stMarkdown p) {
|
|
||||||
/* 这里很难直接针对 user/assistant 做不同背景,通过 JS 或特定类名较难,
|
|
||||||
Streamlit 原生 chat_message 会自动处理头像,我们主要靠边框和布局区分 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 输入框样式 */
|
|
||||||
.stTextInput > div > div > input,
|
|
||||||
.stTextArea > div > div > textarea {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
color: #1A1A1A;
|
|
||||||
border: 1px solid #0056B3; /* 蓝色边框 */
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.stTextInput > div > div > input:focus,
|
|
||||||
.stTextArea > div > div > textarea:focus {
|
|
||||||
border-color: #003D80;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 86, 179, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式 */
|
|
||||||
.stButton > button {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
color: #0056B3;
|
|
||||||
border: 1px solid #0056B3;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.stButton > button:hover {
|
|
||||||
background-color: #0056B3;
|
|
||||||
color: #FFFFFF;
|
|
||||||
}
|
|
||||||
.stButton > button[kind="primary"] {
|
|
||||||
background-color: #0056B3;
|
|
||||||
color: #FFFFFF;
|
|
||||||
border: 1px solid #0056B3;
|
|
||||||
}
|
|
||||||
.stButton > button[kind="primary"]:hover {
|
|
||||||
background-color: #003D80;
|
|
||||||
border-color: #003D80;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 拼接块列表样式优化 */
|
|
||||||
.splice-item {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
border: 1px solid #D1D9E6;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.splice-name-active { color: #1A1A1A; font-weight: 500; }
|
|
||||||
.splice-name-inactive { color: #8898AA; text-decoration: line-through; }
|
|
||||||
|
|
||||||
/* 顶部工具栏 */
|
|
||||||
.top-bar {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-bottom: 1px solid #D1D9E6;
|
|
||||||
margin: -10px -10px 10px -10px; /* 抵消默认 padding */
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 可折叠工具栏 */
|
|
||||||
.collapsible-toolbar {
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
border-bottom: 1px solid #D1D9E6;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏工具栏时的样式 */
|
|
||||||
.toolbar-hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏切换按钮 */
|
|
||||||
.toolbar-toggle {
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 999;
|
|
||||||
background-color: #FFFFFF;
|
|
||||||
border: 1px solid #D1D9E6;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 三栏布局 - 修改部分 */
|
|
||||||
.main-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: calc(100vh - 60px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.three-column-layout {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-column, .middle-column, .right-column {
|
|
||||||
padding: 10px;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-column {
|
|
||||||
flex: 1;
|
|
||||||
border-right: 1px solid #D1D9E6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.middle-column {
|
|
||||||
flex: 3;
|
|
||||||
border-right: 1px solid #D1D9E6;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-column {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 中间列的聊天区域 */
|
|
||||||
.chat-area {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px;
|
|
||||||
height: calc(100% - 80px); /* 减去输入区域的高度 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 中间列的输入区域 */
|
|
||||||
.input-area {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px;
|
|
||||||
border-top: 1px solid #D1D9E6;
|
|
||||||
background-color: #F0F4F8;
|
|
||||||
height: 80px; /* 固定高度 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏Streamlit默认的滚动条样式 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 动态调整布局高度
|
|
||||||
function adjustLayout() {
|
|
||||||
// 获取三个列容器
|
|
||||||
const leftColumn = document.querySelector('.left-column');
|
|
||||||
const middleColumn = document.querySelector('.middle-column');
|
|
||||||
const rightColumn = document.querySelector('.right-column');
|
|
||||||
|
|
||||||
// 设置高度为视口高度减去顶部工具栏高度
|
|
||||||
const height = window.innerHeight - 60; // 减去顶部工具栏的高度
|
|
||||||
|
|
||||||
if (leftColumn) leftColumn.style.height = `${height}px`;
|
|
||||||
if (middleColumn) middleColumn.style.height = `${height}px`;
|
|
||||||
if (rightColumn) rightColumn.style.height = `${height}px`;
|
|
||||||
|
|
||||||
// 调整聊天区域高度
|
|
||||||
const chatArea = document.querySelector('.chat-area');
|
|
||||||
if (chatArea) {
|
|
||||||
const inputArea = document.querySelector('.input-area');
|
|
||||||
const inputHeight = inputArea ? inputArea.offsetHeight : 80;
|
|
||||||
chatArea.style.height = `${height - inputHeight}px`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面加载时调整布局
|
|
||||||
window.addEventListener('load', adjustLayout);
|
|
||||||
|
|
||||||
// 窗口大小改变时重新调整布局
|
|
||||||
window.addEventListener('resize', adjustLayout);
|
|
||||||
|
|
||||||
// 每次Streamlit重新渲染后调整布局
|
|
||||||
document.addEventListener('newElementRendered', adjustLayout);
|
|
||||||
</script>
|
|
||||||
""", unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# --- 状态初始化 ---
|
|
||||||
if "messages" not in st.session_state:
|
|
||||||
# 初始化一些示例数据,方便查看效果
|
|
||||||
st.session_state.messages = [
|
|
||||||
{"role": "assistant", "content": "你好!我是你的 AI 工作流助手。系统已就绪,请开始对话。"},
|
|
||||||
{"role": "user", "content": "帮我生成一个角色卡,需要包含姓名、年龄和背景故事。"},
|
|
||||||
{"role": "assistant",
|
|
||||||
"content": "好的,这是一个示例角色卡:<br><b>姓名</b>: 艾莉娅<br><b>年龄</b>: 24<br><b>背景</b>: 一位来自北方边境的流浪法师。<br><i>(这是 HTML 渲染测试)</i>"}
|
|
||||||
]
|
|
||||||
if "render_html" not in st.session_state:
|
|
||||||
st.session_state.render_html = True # 默认开启 HTML 渲染以展示效果
|
|
||||||
if "image_folder" not in st.session_state:
|
|
||||||
st.session_state.image_folder = "./assets/images"
|
|
||||||
if "splice_blocks" not in st.session_state:
|
|
||||||
st.session_state.splice_blocks = [
|
|
||||||
{"id": 1, "name": "[必看] 系统指令", "active": True, "type": "system"},
|
|
||||||
{"id": 2, "name": "A.U.T.O. 预设设置", "active": True, "type": "system"},
|
|
||||||
{"id": 3, "name": "世界书:人物关系", "active": False, "type": "world"},
|
|
||||||
{"id": 4, "name": "Chat History (自动)", "active": True, "type": "history", "editable": False},
|
|
||||||
]
|
|
||||||
if "toolbar_visible" not in st.session_state:
|
|
||||||
st.session_state.toolbar_visible = True
|
|
||||||
|
|
||||||
# --- 顶部工具栏 ---
|
|
||||||
# 工具栏切换按钮
|
|
||||||
st.markdown("""
|
|
||||||
<div class="toolbar-toggle" onclick="toggleToolbar()">
|
|
||||||
<span id="toolbar-icon">▼</span>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
function toggleToolbar() {
|
|
||||||
var toolbar = document.querySelector('.collapsible-toolbar');
|
|
||||||
var icon = document.getElementById('toolbar-icon');
|
|
||||||
if (toolbar.style.display === 'none') {
|
|
||||||
toolbar.style.display = 'block';
|
|
||||||
icon.textContent = '▼';
|
|
||||||
} else {
|
|
||||||
toolbar.style.display = 'none';
|
|
||||||
icon.textContent = '▲';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
""", unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# 工具栏内容
|
|
||||||
if st.session_state.toolbar_visible:
|
|
||||||
with st.container():
|
|
||||||
c_top1, c_top2, c_top3 = st.columns([1, 6, 1])
|
|
||||||
with c_top1:
|
|
||||||
if st.button("📂 打开", key="btn_open"):
|
|
||||||
st.toast("打开会话功能预留")
|
|
||||||
if st.button("💾 保存", key="btn_save"):
|
|
||||||
st.toast("会话已保存")
|
|
||||||
with c_top2:
|
|
||||||
st.markdown("<h3 style='margin:0; color:#0056B3;'>AI WorkFlow Engine</h3>", unsafe_allow_html=True)
|
|
||||||
with c_top3:
|
|
||||||
if st.button("⚙️ 设置", key="btn_settings"):
|
|
||||||
st.toast("全局设置预留")
|
|
||||||
|
|
||||||
st.divider()
|
|
||||||
|
|
||||||
# --- 三栏布局 ---
|
|
||||||
col_left, col_mid, col_right = st.columns([1, 3, 1], gap="small")
|
|
||||||
|
|
||||||
# =======================
|
|
||||||
# 1. 左侧:预设与拼接管理 (蓝白风格适配)
|
|
||||||
# =======================
|
|
||||||
with col_left:
|
|
||||||
# 使用自定义容器类
|
|
||||||
st.markdown('<div class="left-column">', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
st.markdown("#### 📜 全局预设")
|
|
||||||
c_pre1, c_pre2 = st.columns([4, 1])
|
|
||||||
with c_pre1:
|
|
||||||
preset_options = ["Default", "A.U.T.O. v1.47", "Roleplay Pro"]
|
|
||||||
st.selectbox("选择预设", preset_options, label_visibility="collapsed")
|
|
||||||
with c_pre2:
|
|
||||||
if st.button("📥", key="btn_import", help="导入预设"):
|
|
||||||
st.toast("导入功能预留")
|
|
||||||
|
|
||||||
st.markdown("#### ⚙️ 生成参数")
|
|
||||||
c_p1, c_p2 = st.columns(2)
|
|
||||||
with c_p1:
|
|
||||||
st.slider("温度", 0.0, 2.0, 1.0, key="slider_temp")
|
|
||||||
st.slider("Top P", 0.0, 1.0, 0.9, key="slider_top_p")
|
|
||||||
with c_p2:
|
|
||||||
st.slider("频率惩罚", 0.0, 2.0, 1.0, key="slider_freq")
|
|
||||||
st.slider("存在惩罚", 0.0, 2.0, 0.0, key="slider_pres")
|
|
||||||
|
|
||||||
c_l1, c_l2 = st.columns(2)
|
|
||||||
with c_l1:
|
|
||||||
st.number_input("上下文长度", value=30000, key="input_ctx")
|
|
||||||
with c_l2:
|
|
||||||
st.number_input("最大回复", value=500, key="input_max")
|
|
||||||
|
|
||||||
st.checkbox("✅ 流式传输", value=True, key="chk_stream")
|
|
||||||
|
|
||||||
st.markdown("#### 🧩 内容拼接块")
|
|
||||||
st.caption("控制发送至后端的上下文组成")
|
|
||||||
|
|
||||||
# 渲染拼接块列表
|
|
||||||
for block in st.session_state.splice_blocks:
|
|
||||||
with st.container():
|
|
||||||
# 自定义行布局模拟列表项
|
|
||||||
cols = st.columns([0.5, 3, 0.5, 0.5])
|
|
||||||
with cols[0]:
|
|
||||||
icon = "🌍" if block['type'] == 'world' else ("💬" if block['type'] == 'history' else "📄")
|
|
||||||
st.write(icon)
|
|
||||||
with cols[1]:
|
|
||||||
name_class = "splice-name-active" if block['active'] else "splice-name-inactive"
|
|
||||||
st.markdown(f"<div class='{name_class}' style='font-size:0.85em;'>{block['name']}</div>",
|
|
||||||
unsafe_allow_html=True)
|
|
||||||
with cols[2]:
|
|
||||||
disabled = not block.get('editable', True)
|
|
||||||
if st.button("✏️", key=f"edit_{block['id']}", disabled=disabled):
|
|
||||||
st.toast(f"编辑:{block['name']}")
|
|
||||||
with cols[3]:
|
|
||||||
is_active = st.checkbox("✓", value=block['active'], key=f"act_{block['id']}",
|
|
||||||
label_visibility="collapsed")
|
|
||||||
if is_active != block['active']:
|
|
||||||
block['active'] = is_active
|
|
||||||
st.rerun()
|
|
||||||
st.markdown("<div style='height:1px; background:#E1E8F0; margin:4px 0;'></div>", unsafe_allow_html=True)
|
|
||||||
|
|
||||||
if st.button("+ 添加拼接块", use_container_width=True):
|
|
||||||
st.toast("添加新功能预留")
|
|
||||||
|
|
||||||
st.markdown('</div>', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# =======================
|
|
||||||
# 2. 中间:流式对话区 (动态读取历史)
|
|
||||||
# =======================
|
|
||||||
with col_mid:
|
|
||||||
# 使用自定义容器类
|
|
||||||
st.markdown('<div class="middle-column">', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# --- 控制区域 ---
|
|
||||||
c_ctrl1, c_ctrl2, c_ctrl3 = st.columns([3, 2, 1])
|
|
||||||
|
|
||||||
with c_ctrl1:
|
|
||||||
# --- 数据集选择下拉框 ---
|
|
||||||
try:
|
|
||||||
response = requests.get(f"{BACKEND_URL}/get_all_role_and_chat")
|
|
||||||
if response.status_code == 200:
|
|
||||||
datasets = response.json()
|
|
||||||
dataset_options = list(datasets.keys())
|
|
||||||
else:
|
|
||||||
st.error(f"获取数据集失败: {response.status_code}")
|
|
||||||
dataset_options = []
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
st.error(f"请求数据集时出错: {e}")
|
|
||||||
dataset_options = []
|
|
||||||
|
|
||||||
selected_dataset = st.selectbox(
|
|
||||||
"选择数据集",
|
|
||||||
dataset_options,
|
|
||||||
index=0 if dataset_options else None,
|
|
||||||
key="dataset_selector"
|
|
||||||
)
|
|
||||||
|
|
||||||
with c_ctrl2:
|
|
||||||
# --- 文件路径选择下拉框 ---
|
|
||||||
# 初始化两层下拉框的数据结构
|
|
||||||
chat_history_options = {}
|
|
||||||
file_options = []
|
|
||||||
|
|
||||||
if selected_dataset:
|
|
||||||
# 使用已经获取的数据集数据
|
|
||||||
chat_history_options = datasets
|
|
||||||
# 获取当前选中数据集对应的文件列表
|
|
||||||
file_options = chat_history_options.get(selected_dataset, [])
|
|
||||||
|
|
||||||
# 第一层下拉框:选择聊天会话(这里应该直接使用selected_dataset)
|
|
||||||
# 不需要再创建一个selectbox,因为已经选择了数据集
|
|
||||||
selected_chat_session = selected_dataset
|
|
||||||
|
|
||||||
# 第二层下拉框:选择文件路径(value列表)
|
|
||||||
if selected_chat_session:
|
|
||||||
file_options = chat_history_options.get(selected_chat_session, [])
|
|
||||||
if file_options:
|
|
||||||
# 提取文件名并去除.jsonl后缀,用于显示
|
|
||||||
display_names = [os.path.basename(f).replace('.jsonl', '') for f in file_options]
|
|
||||||
|
|
||||||
# 创建文件名到完整路径的映射
|
|
||||||
file_name_to_path = {os.path.basename(f).replace('.jsonl', ''): f for f in file_options}
|
|
||||||
|
|
||||||
selected_file_display = st.selectbox(
|
|
||||||
"选择聊天",
|
|
||||||
display_names,
|
|
||||||
index=0 if display_names else None,
|
|
||||||
key="file_selector"
|
|
||||||
)
|
|
||||||
|
|
||||||
if selected_file_display:
|
|
||||||
# 保存完整路径到session_state
|
|
||||||
st.session_state.selected_file_path = file_name_to_path[selected_file_display]
|
|
||||||
else:
|
|
||||||
# 如果没有文件路径,清空选择
|
|
||||||
if "file_selector" in st.session_state:
|
|
||||||
del st.session_state["file_selector"]
|
|
||||||
else:
|
|
||||||
# 如果没有选择会话,清空选择
|
|
||||||
if "file_selector" in st.session_state:
|
|
||||||
del st.session_state["file_selector"]
|
|
||||||
|
|
||||||
with c_ctrl3:
|
|
||||||
# HTML 渲染切换
|
|
||||||
toggle_html = st.toggle("HTML 渲染", value=st.session_state.render_html, key="html_toggle")
|
|
||||||
if toggle_html != st.session_state.render_html:
|
|
||||||
st.session_state.render_html = toggle_html
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
# 显示当前会话信息
|
|
||||||
if selected_dataset and 'selected_file_path' in st.session_state:
|
|
||||||
file_name = os.path.basename(st.session_state.selected_file_path).replace('.jsonl', '')
|
|
||||||
st.caption(f"当前会话:{selected_dataset} - {file_name}")
|
|
||||||
else:
|
|
||||||
st.caption("当前会话:Active_Session_01")
|
|
||||||
|
|
||||||
# --- 核心:动态渲染历史记录 ---
|
|
||||||
# 使用自定义容器类包裹聊天区域
|
|
||||||
st.markdown('<div class="chat-area">', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# 如果选择了聊天记录,则显示该记录
|
|
||||||
if hasattr(st.session_state, 'selected_chat_data') and st.session_state.selected_chat_data:
|
|
||||||
# 显示选中的聊天记录
|
|
||||||
msg = st.session_state.selected_chat_data
|
|
||||||
with st.chat_message(msg["role"]):
|
|
||||||
content = msg["content"]
|
|
||||||
|
|
||||||
# 根据开关决定是否解析 HTML
|
|
||||||
if st.session_state.render_html and msg["role"] == "assistant":
|
|
||||||
st.markdown(content, unsafe_allow_html=True)
|
|
||||||
else:
|
|
||||||
st.markdown(content)
|
|
||||||
|
|
||||||
# 显示其他信息
|
|
||||||
with st.expander("详细信息", expanded=False):
|
|
||||||
st.json(msg)
|
|
||||||
else:
|
|
||||||
# 否则显示session_state中的消息历史
|
|
||||||
for i, msg in enumerate(st.session_state.messages):
|
|
||||||
with st.chat_message(msg["role"]):
|
|
||||||
content = msg["content"]
|
|
||||||
|
|
||||||
if st.session_state.render_html and msg["role"] == "assistant":
|
|
||||||
st.markdown(content, unsafe_allow_html=True)
|
|
||||||
else:
|
|
||||||
st.markdown(content)
|
|
||||||
|
|
||||||
st.markdown('</div>', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# --- 输入区域 ---
|
|
||||||
# 使用自定义容器类包裹输入区域
|
|
||||||
st.markdown('<div class="input-area">', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# 聊天输入框
|
|
||||||
user_input = st.chat_input("输入消息... (支持 /命令)")
|
|
||||||
|
|
||||||
if user_input:
|
|
||||||
# 1. 将用户输入加入历史
|
|
||||||
st.session_state.messages.append({"role": "user", "content": user_input})
|
|
||||||
|
|
||||||
# 2. 触发重新渲染
|
|
||||||
with st.chat_message("assistant"):
|
|
||||||
message_placeholder = st.empty()
|
|
||||||
message_placeholder.markdown("*思考中...*")
|
|
||||||
|
|
||||||
# === 模拟后端流式响应 ===
|
|
||||||
full_response = ""
|
|
||||||
simulated_text = f"收到您的指令:**{user_input}**。\n\n这是一个测试回复,如果您开启了 **HTML 渲染**,下方将显示彩色文本和表格:<br><span style='color:#0056B3; font-weight:bold;'>蓝色高亮文本</span><br><table border='1' style='border-collapse:collapse; width:100%;'><tr><th>属性</th><th>值</th></tr><tr><td>状态</td><td>正常</td></tr></table>"
|
|
||||||
|
|
||||||
chunks = simulated_text.split(" ")
|
|
||||||
for chunk in chunks:
|
|
||||||
full_response += chunk + " "
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if st.session_state.render_html:
|
|
||||||
message_placeholder.markdown(full_response, unsafe_allow_html=True)
|
|
||||||
else:
|
|
||||||
message_placeholder.markdown(full_response)
|
|
||||||
|
|
||||||
# 3. 将完整的助手回复存入历史
|
|
||||||
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
|
||||||
|
|
||||||
# 强制刷新以确保持久化显示
|
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
st.markdown('</div>', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
st.markdown('</div>', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
# =======================
|
|
||||||
# 3. 右侧:图片与骰子 (蓝白风格)
|
|
||||||
# =======================
|
|
||||||
with col_right:
|
|
||||||
# 使用自定义容器类
|
|
||||||
st.markdown('<div class="right-column">', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
st.markdown("#### 🖼️ 本地图库")
|
|
||||||
img_path = Path(st.session_state.image_folder)
|
|
||||||
img_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
images = [f for f in os.listdir(img_path) if f.endswith(('.png', '.jpg', '.jpeg', '.webp'))]
|
|
||||||
if images:
|
|
||||||
cols = st.columns(2)
|
|
||||||
for idx, img_name in enumerate(images[:8]):
|
|
||||||
with cols[idx % 2]:
|
|
||||||
# 增加白色背景和边框,使图片在浅灰底上更突出
|
|
||||||
st.markdown(
|
|
||||||
f"<div style='background:white; padding:5px; border-radius:4px; border:1px solid #ddd;'>",
|
|
||||||
unsafe_allow_html=True)
|
|
||||||
st.image(str(img_path / img_name), use_container_width=True)
|
|
||||||
st.caption(img_name)
|
|
||||||
st.markdown("</div>", unsafe_allow_html=True)
|
|
||||||
else:
|
|
||||||
st.info("图片文件夹为空")
|
|
||||||
except Exception as e:
|
|
||||||
st.error(f"读取错误:{e}")
|
|
||||||
|
|
||||||
st.divider()
|
|
||||||
|
|
||||||
st.markdown("#### 🎲 检定工具")
|
|
||||||
tab_table, tab_dice = st.tabs(["📊 表格", "🎲 骰子"])
|
|
||||||
|
|
||||||
with tab_table:
|
|
||||||
st.markdown("**动态数据表**")
|
|
||||||
st.dataframe(
|
|
||||||
{"属性": ["力量", "敏捷", "智力"], "数值": [50, 60, 70]},
|
|
||||||
hide_index=True,
|
|
||||||
use_container_width=True
|
|
||||||
)
|
|
||||||
|
|
||||||
with tab_dice:
|
|
||||||
roll_type = st.radio("类型", ["难度检定", "对抗骰"], horizontal=True)
|
|
||||||
diff_opts = ["极难 (95)", "困难 (75)", "普通 (50)"]
|
|
||||||
selected_diff = st.selectbox("难度", diff_opts)
|
|
||||||
|
|
||||||
c_r1, c_r2 = st.columns(2)
|
|
||||||
with c_r1:
|
|
||||||
if st.button("🎲 投掷", type="primary", use_container_width=True):
|
|
||||||
res = random.randint(1, 100)
|
|
||||||
color = "#d9534f" if res > int(selected_diff.split('(')[1].strip(')')) else "#5cb85c"
|
|
||||||
st.markdown(
|
|
||||||
f"<div style='text-align:center; font-size:1.5em; color:{color}; font-weight:bold;'>{res}</div>",
|
|
||||||
unsafe_allow_html=True)
|
|
||||||
with c_r2:
|
|
||||||
st.caption(f"目标:{selected_diff.split('(')[1].strip(')')}")
|
|
||||||
|
|
||||||
st.markdown('</div>', unsafe_allow_html=True)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 497 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
def render_chat_window(backend_url):
|
|
||||||
st.subheader("💬 流式对话")
|
|
||||||
|
|
||||||
# 聊天历史显示
|
|
||||||
chat_container = st.container()
|
|
||||||
with chat_container:
|
|
||||||
for message in st.session_state.messages:
|
|
||||||
with st.chat_message(message["role"]):
|
|
||||||
st.markdown(message["content"])
|
|
||||||
# 如果有关联图片,也可以在这里显示
|
|
||||||
if "images" in message:
|
|
||||||
for img_url in message["images"]:
|
|
||||||
st.image(img_url, width=200)
|
|
||||||
|
|
||||||
# 输入框
|
|
||||||
if prompt := st.chat_input("输入消息..."):
|
|
||||||
# 1. 显示用户消息
|
|
||||||
st.session_state.messages.append({"role": "user", "content": prompt})
|
|
||||||
with st.chat_message("user"):
|
|
||||||
st.markdown(prompt)
|
|
||||||
|
|
||||||
# 2. 调用后端流式接口
|
|
||||||
with st.chat_message("assistant"):
|
|
||||||
message_placeholder = st.empty()
|
|
||||||
full_response = ""
|
|
||||||
|
|
||||||
# 模拟流式接收 (实际需使用 requests stream 或 websocket)
|
|
||||||
# POST /api/role/stream
|
|
||||||
try:
|
|
||||||
# 伪代码示例:
|
|
||||||
# with requests.post(f"{backend_url}/api/chat/stream", json={"message": prompt}, stream=True) as r:
|
|
||||||
# for chunk in r.iter_content(chunk_size=None):
|
|
||||||
# if chunk:
|
|
||||||
# full_response += chunk.decode('utf-8')
|
|
||||||
# message_placeholder.markdown(full_response + "▌")
|
|
||||||
|
|
||||||
# 演示用静态延迟
|
|
||||||
import time
|
|
||||||
response_text = "这是一个流式响应的演示。后端正在处理您的请求..."
|
|
||||||
for char in response_text:
|
|
||||||
full_response += char
|
|
||||||
message_placeholder.markdown(full_response + "▌")
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
message_placeholder.markdown(full_response)
|
|
||||||
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
st.error(f"连接后端失败: {e}")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import random
|
|
||||||
|
|
||||||
|
|
||||||
def render_dice_roller():
|
|
||||||
st.subheader("🎲 命运骰子")
|
|
||||||
|
|
||||||
col1, col2 = st.columns(2)
|
|
||||||
|
|
||||||
with col1:
|
|
||||||
d20 = st.button("D20", use_container_width=True)
|
|
||||||
if d20:
|
|
||||||
roll = random.randint(1, 20)
|
|
||||||
st.metric("结果", roll, delta=None)
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
d6 = st.button("D6", use_container_width=True)
|
|
||||||
if d6:
|
|
||||||
roll = random.randint(1, 6)
|
|
||||||
st.metric("结果", roll, delta=None)
|
|
||||||
|
|
||||||
# 自定义骰子
|
|
||||||
sides = st.number_input("面数", min_value=2, max_value=100, value=10)
|
|
||||||
if st.button(f"投掷 D{sides}", use_container_width=True):
|
|
||||||
roll = random.randint(1, sides)
|
|
||||||
st.success(f"🎲 结果是: **{roll}**")
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
|
|
||||||
|
|
||||||
def render_image_gallery(backend_url):
|
|
||||||
st.subheader("🖼️ 生成画廊")
|
|
||||||
|
|
||||||
# 这里通常轮询后端获取最新生成的图片
|
|
||||||
# GET /api/images/latest
|
|
||||||
|
|
||||||
if not st.session_state.generated_images:
|
|
||||||
st.info("暂无生成图片,对话中触发绘图后将在此显示。")
|
|
||||||
else:
|
|
||||||
cols = st.columns(2)
|
|
||||||
for idx, img_url in enumerate(st.session_state.generated_images[-4:]): # 只显示最近4张
|
|
||||||
with cols[idx % 2]:
|
|
||||||
st.image(img_url, use_container_width=True)
|
|
||||||
st.caption(f"Image {idx + 1}")
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
def render_settings_panel(backend_url):
|
|
||||||
st.subheader("⚙️ 预设设置")
|
|
||||||
|
|
||||||
# 模拟获取预设列表 (实际应调用后端 API)
|
|
||||||
# GET /api/presets
|
|
||||||
try:
|
|
||||||
# response = requests.get(f"{backend_url}/api/presets")
|
|
||||||
# presets = response.json()
|
|
||||||
presets = ["角色扮演-奇幻", "项目管理", "旅行规划", "自定义"] # 占位数据
|
|
||||||
except:
|
|
||||||
presets = ["默认预设"]
|
|
||||||
|
|
||||||
selected_preset = st.selectbox("选择预设模板", presets, index=0)
|
|
||||||
|
|
||||||
st.text_area("系统指令 (System)", height=100, placeholder="在此输入系统级指令...")
|
|
||||||
|
|
||||||
st.checkbox("启用状态记忆", value=True)
|
|
||||||
st.checkbox("启用异步生图", value=True)
|
|
||||||
st.checkbox("启用输入预处理", value=False)
|
|
||||||
|
|
||||||
st.info("💡 修改配置后自动生效,无需重启。")
|
|
||||||
|
|
||||||
# 保存按钮 (调用后端更新配置)
|
|
||||||
if st.button("💾 保存配置", use_container_width=True):
|
|
||||||
st.success("配置已保存!")
|
|
||||||
# requests.post(f"{backend_url}/api/config", json={...})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import streamlit as st
|
|
||||||
|
|
||||||
|
|
||||||
def render_toolbar(backend_url):
|
|
||||||
col1, col2, col3 = st.columns([1, 2, 1])
|
|
||||||
|
|
||||||
with col1:
|
|
||||||
st.logo("https://streamlit.io/images/brand/streamlit-logo-primary-colormark-darktext.png",
|
|
||||||
size="large") # 可替换为项目Logo
|
|
||||||
|
|
||||||
with col2:
|
|
||||||
st.title("AI Tavern 工作流引擎")
|
|
||||||
|
|
||||||
with col3:
|
|
||||||
if st.button("🔄 重置会话", use_container_width=True):
|
|
||||||
st.session_state.messages = []
|
|
||||||
st.rerun()
|
|
||||||
# 这里可以添加更多工具栏按钮,如:知识库管理、系统状态等
|
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- React 应用将挂载到这个 div 上 -->
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- Vite 会自动注入这里的脚本标签 -->
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
frontend/nginx.conf
Normal file
42
frontend/nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 启用 gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||||
|
|
||||||
|
# 处理前端路由
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理(如果需要)
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://backend:8000/api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
}
|
||||||
17
frontend/node_modules/.bin/acorn.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/acorn.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\acorn\bin\acorn" %*
|
||||||
17
frontend/node_modules/.bin/csv2json.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/csv2json.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\d3-dsv\bin\dsv2json.js" %*
|
||||||
28
frontend/node_modules/.bin/csv2json.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/csv2json.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
17
frontend/node_modules/.bin/dsv2dsv.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/dsv2dsv.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\d3-dsv\bin\dsv2dsv.js" %*
|
||||||
28
frontend/node_modules/.bin/dsv2json.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/dsv2json.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../d3-dsv/bin/dsv2json.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
28
frontend/node_modules/.bin/installServerIntoExtension.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/installServerIntoExtension.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../vscode-languageserver/bin/installServerIntoExtension" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../vscode-languageserver/bin/installServerIntoExtension" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../vscode-languageserver/bin/installServerIntoExtension" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../vscode-languageserver/bin/installServerIntoExtension" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
17
frontend/node_modules/.bin/jsesc.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/jsesc.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jsesc\bin\jsesc" %*
|
||||||
28
frontend/node_modules/.bin/jsesc.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/jsesc.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../jsesc/bin/jsesc" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../jsesc/bin/jsesc" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../jsesc/bin/jsesc" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
17
frontend/node_modules/.bin/json2dsv.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/json2dsv.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\d3-dsv\bin\json2dsv.js" %*
|
||||||
28
frontend/node_modules/.bin/json2dsv.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/json2dsv.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../d3-dsv/bin/json2dsv.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../d3-dsv/bin/json2dsv.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../d3-dsv/bin/json2dsv.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../d3-dsv/bin/json2dsv.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
16
frontend/node_modules/.bin/json5
generated
vendored
Normal file
16
frontend/node_modules/.bin/json5
generated
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../json5/lib/cli.js" "$@"
|
||||||
|
fi
|
||||||
17
frontend/node_modules/.bin/json5.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/json5.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\json5\lib\cli.js" %*
|
||||||
28
frontend/node_modules/.bin/json5.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/json5.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../json5/lib/cli.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../json5/lib/cli.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../json5/lib/cli.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
17
frontend/node_modules/.bin/katex.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/katex.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\katex\cli.js" %*
|
||||||
28
frontend/node_modules/.bin/katex.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/katex.ps1
generated
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||||
|
|
||||||
|
$exe=""
|
||||||
|
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||||
|
# Fix case when both the Windows and Linux builds of Node
|
||||||
|
# are installed in the same directory
|
||||||
|
$exe=".exe"
|
||||||
|
}
|
||||||
|
$ret=0
|
||||||
|
if (Test-Path "$basedir/node$exe") {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "$basedir/node$exe" "$basedir/../katex/cli.js" $args
|
||||||
|
} else {
|
||||||
|
& "$basedir/node$exe" "$basedir/../katex/cli.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
} else {
|
||||||
|
# Support pipeline input
|
||||||
|
if ($MyInvocation.ExpectingInput) {
|
||||||
|
$input | & "node$exe" "$basedir/../katex/cli.js" $args
|
||||||
|
} else {
|
||||||
|
& "node$exe" "$basedir/../katex/cli.js" $args
|
||||||
|
}
|
||||||
|
$ret=$LASTEXITCODE
|
||||||
|
}
|
||||||
|
exit $ret
|
||||||
16
frontend/node_modules/.bin/parser
generated
vendored
Normal file
16
frontend/node_modules/.bin/parser
generated
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||||
|
|
||||||
|
case `uname` in
|
||||||
|
*CYGWIN*|*MINGW*|*MSYS*)
|
||||||
|
if command -v cygpath > /dev/null 2>&1; then
|
||||||
|
basedir=`cygpath -w "$basedir"`
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -x "$basedir/node" ]; then
|
||||||
|
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||||
|
else
|
||||||
|
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
|
||||||
|
fi
|
||||||
17
frontend/node_modules/.bin/semver.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/semver.cmd
generated
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@ECHO off
|
||||||
|
GOTO start
|
||||||
|
:find_dp0
|
||||||
|
SET dp0=%~dp0
|
||||||
|
EXIT /b
|
||||||
|
:start
|
||||||
|
SETLOCAL
|
||||||
|
CALL :find_dp0
|
||||||
|
|
||||||
|
IF EXIST "%dp0%\node.exe" (
|
||||||
|
SET "_prog=%dp0%\node.exe"
|
||||||
|
) ELSE (
|
||||||
|
SET "_prog=node"
|
||||||
|
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||||
|
)
|
||||||
|
|
||||||
|
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user