fix:初始化
238
README.md
@@ -0,0 +1,238 @@
|
|||||||
|
# AI 小说创作平台
|
||||||
|
|
||||||
|
基于 React、ProComponents、Ollama 和 Qwen3:8B 构建的本地化 AI 小说创作平台。
|
||||||
|
|
||||||
|
## 🚀 功能特点
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- **模型设置** - 二级菜单结构,专门配置本地 Ollama 模型
|
||||||
|
- **智能检测** - 自动检测本地 Ollama 服务状态和已安装模型
|
||||||
|
- **小说卡片管理** - 以卡片形式展示小说列表,支持新增、编辑、删除
|
||||||
|
- **AI 章节生成** - 集成 Ollama API 进行智能章节生成,支持自定义提示词
|
||||||
|
- **内容管理** - 自动保存到浏览器 IndexedDB,支持查看、编辑、导出
|
||||||
|
- **Markdown 导出** - 支持单章或整本小说导出为 Markdown 格式
|
||||||
|
|
||||||
|
### 最新优化
|
||||||
|
- ✅ **二级菜单** - 设置 > 模型的层级结构
|
||||||
|
- ✅ **IndexedDB 存储** - 替代 localStorage,支持更大存储容量
|
||||||
|
- ✅ **智能模型检测** - 自动检测本地 Ollama 已安装的模型
|
||||||
|
- ✅ **模型验证** - 限制只能选择本地已安装的模型
|
||||||
|
- ✅ **连接状态监控** - 实时显示 Ollama 服务连接状态
|
||||||
|
- ✅ **现代化 UI** - 干净整洁的界面设计
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **前端**: React + TypeScript + Ant Design
|
||||||
|
- **路由**: React Router v6
|
||||||
|
- **AI 集成**: Ollama REST API
|
||||||
|
- **数据存储**: IndexedDB
|
||||||
|
- **文档处理**: ReactMarkdown
|
||||||
|
|
||||||
|
## 📋 前置要求
|
||||||
|
|
||||||
|
### 必需软件
|
||||||
|
1. **Node.js** (v14 或更高版本)
|
||||||
|
2. **Ollama** - 本地 AI 模型运行环境
|
||||||
|
|
||||||
|
### Ollama 安装
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install ollama
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
curl -fsSL https://ollama.com/install.sh | sh
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
# 从 https://ollama.com/download 下载安装
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推荐模型
|
||||||
|
```bash
|
||||||
|
# Qwen2.5 系列(推荐)
|
||||||
|
ollama pull qwen2.5:7b
|
||||||
|
ollama pull qwen2.5:14b
|
||||||
|
|
||||||
|
# Llama3 系列
|
||||||
|
ollama pull llama3:8b
|
||||||
|
|
||||||
|
# 其他中文模型
|
||||||
|
ollama pull qwen2:7b
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动 Ollama 服务
|
||||||
|
```bash
|
||||||
|
ollama serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动开发服务器
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 访问应用
|
||||||
|
打开浏览器访问 http://localhost:3000
|
||||||
|
|
||||||
|
## 📖 使用指南
|
||||||
|
|
||||||
|
### 首次配置
|
||||||
|
|
||||||
|
1. **启动 Ollama 服务**
|
||||||
|
```bash
|
||||||
|
ollama serve
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **进入模型设置**
|
||||||
|
- 点击左侧菜单的"设置" > "模型"
|
||||||
|
- 默认 Ollama 地址:`http://localhost:11434`
|
||||||
|
|
||||||
|
3. **测试连接**
|
||||||
|
- 点击"测试连接"按钮验证 Ollama 服务状态
|
||||||
|
|
||||||
|
4. **检测模型**
|
||||||
|
- 点击"检测模型"按钮获取本地已安装模型
|
||||||
|
- 系统会显示模型名称和大小信息
|
||||||
|
|
||||||
|
5. **选择模型**
|
||||||
|
- 从下拉列表中选择本地已安装的模型
|
||||||
|
- 如需新模型,先使用 `ollama pull 模型名` 安装
|
||||||
|
|
||||||
|
6. **调整参数**
|
||||||
|
- **Temperature**: 0.7(控制随机性,0-2)
|
||||||
|
- **Top P**: 0.9(控制多样性,0-1)
|
||||||
|
- **最大 Tokens**: 2000(生成长度)
|
||||||
|
|
||||||
|
### 创作流程
|
||||||
|
|
||||||
|
1. **创建小说**
|
||||||
|
- 进入"小说管理"
|
||||||
|
- 点击"新建小说"
|
||||||
|
- 填写书名、题材、大纲、人物设定
|
||||||
|
|
||||||
|
2. **生成章节**
|
||||||
|
- 点击小说卡片进入详情页
|
||||||
|
- 点击"生成新章节"
|
||||||
|
- 输入章节标题和可选的提示词
|
||||||
|
- 等待 AI 生成内容
|
||||||
|
|
||||||
|
3. **管理内容**
|
||||||
|
- **查看**: 点击"查看"按钮预览 Markdown 格式内容
|
||||||
|
- **编辑**: 点击"编辑"按钮修改章节内容
|
||||||
|
- **导出**: 点击"导出"按钮下载单章或整本小说
|
||||||
|
|
||||||
|
## 🔧 高级配置
|
||||||
|
|
||||||
|
### 自定义 Ollama 地址
|
||||||
|
如果 Ollama 运行在其他地址:
|
||||||
|
```
|
||||||
|
http://your-server:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模型选择建议
|
||||||
|
- **轻量级**: qwen2.5:3b (4GB 内存)
|
||||||
|
- **平衡**: qwen2.5:7b (8GB 内存)
|
||||||
|
- **高质量**: qwen2.5:14b (16GB 内存)
|
||||||
|
- **英文**: llama3:8b (8GB 内存)
|
||||||
|
|
||||||
|
### 生成参数调优
|
||||||
|
- **创意写作**: Temperature 0.8-1.0
|
||||||
|
- **技术内容**: Temperature 0.3-0.5
|
||||||
|
- **平衡**: Temperature 0.7(默认)
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # React 组件
|
||||||
|
│ └── MainLayout.tsx # 主布局(左侧菜单+右侧内容)
|
||||||
|
├── pages/ # 页面组件
|
||||||
|
│ ├── NovelList.tsx # 小说列表
|
||||||
|
│ ├── NovelDetail.tsx # 小说详情
|
||||||
|
│ └── ModelSettings.tsx # 模型设置
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
│ ├── indexedDB.ts # IndexedDB 数据存储
|
||||||
|
│ ├── ollama.ts # AI 服务集成
|
||||||
|
│ └── fileManager.ts # 文件导出
|
||||||
|
└── types/ # TypeScript 类型定义
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 常见问题
|
||||||
|
|
||||||
|
### Q: 无法连接 Ollama 服务
|
||||||
|
**A**:
|
||||||
|
1. 确保 Ollama 服务正在运行:`ollama serve`
|
||||||
|
2. 检查服务地址是否正确
|
||||||
|
3. 确认端口 11434 未被占用
|
||||||
|
|
||||||
|
### Q: 检测不到模型
|
||||||
|
**A**:
|
||||||
|
1. 使用 `ollama list` 查看已安装模型
|
||||||
|
2. 使用 `ollama pull 模型名` 安装新模型
|
||||||
|
3. 点击"检测模型"按钮刷新列表
|
||||||
|
|
||||||
|
### Q: 数据存储在哪里?
|
||||||
|
**A**:
|
||||||
|
- 所有数据存储在浏览器的 IndexedDB 中
|
||||||
|
- 数据库名称:AINovelDB
|
||||||
|
- 建议定期导出重要作品进行备份
|
||||||
|
|
||||||
|
### Q: 章节生成失败
|
||||||
|
**A**:
|
||||||
|
1. 检查 Ollama 服务是否正常
|
||||||
|
2. 确认选择的模型已正确安装
|
||||||
|
3. 尝试降低 `maxTokens` 参数
|
||||||
|
4. 查看浏览器控制台错误信息
|
||||||
|
|
||||||
|
### Q: 生成内容质量不佳
|
||||||
|
**A**:
|
||||||
|
1. 尝试调整 Temperature 参数
|
||||||
|
2. 在提示词中提供更详细的要求
|
||||||
|
3. 完善小说的大纲和人物设定
|
||||||
|
4. 使用更大参数量的模型
|
||||||
|
|
||||||
|
## 🔐 安全说明
|
||||||
|
|
||||||
|
- 所有数据存储在浏览器 IndexedDB 中,不会上传到云端
|
||||||
|
- Ollama 服务完全运行在本地,确保隐私安全
|
||||||
|
- 支持更大容量的数据存储(相比 localStorage)
|
||||||
|
- 建议定期导出重要作品进行备份
|
||||||
|
|
||||||
|
## 📝 开发说明
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行测试
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
- 使用 TypeScript 编写
|
||||||
|
- 遵循 React Hooks 最佳实践
|
||||||
|
- 组件式开发,便于维护和扩展
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 📮 联系方式
|
||||||
|
|
||||||
|
如有问题请提交 Issue 或联系开发者。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**享受 AI 创作的乐趣!** ✨
|
||||||
194
TESTING.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# AI 小说创作平台 - 测试指南
|
||||||
|
|
||||||
|
## 🧪 边界情况测试
|
||||||
|
|
||||||
|
### 1. 首次启动测试(无配置)
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:Ollama 状态: 未连接 | 模型数量: 0 个 | 当前模型: 未配置模型
|
||||||
|
- ✅ 当前模型文字显示为红色
|
||||||
|
- ✅ 显示"前往配置"按钮
|
||||||
|
- ✅ 侧边栏状态显示:服务未连接
|
||||||
|
|
||||||
|
### 2. Ollama 服务未启动测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 确保 Ollama 服务未运行
|
||||||
|
2. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:Ollama 状态: 未连接(红色)
|
||||||
|
- ✅ 显示警告提示:Ollama 服务未连接
|
||||||
|
- ✅ 提示信息:请确保 Ollama 服务正在运行
|
||||||
|
- ✅ 侧边栏显示服务未连接图标
|
||||||
|
|
||||||
|
### 3. 有服务但无模型测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 启动 Ollama 服务
|
||||||
|
2. 不安装任何模型
|
||||||
|
3. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:Ollama 状态: 已连接(绿色)| 模型数量: 0 个(黄色)
|
||||||
|
- ✅ 当前模型: 未配置模型(红色)
|
||||||
|
- ✅ 显示警告提示:未检测到 AI 模型
|
||||||
|
- ✅ 提示信息:请先安装 AI 模型
|
||||||
|
|
||||||
|
### 4. 配置了模型但未安装测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 在设置中选择一个模型(如 qwen2.5:7b)
|
||||||
|
2. 但本地实际没有安装这个模型
|
||||||
|
3. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:Ollama 状态: 已连接 | 模型数量: 0 个
|
||||||
|
- ✅ 当前模型: qwen2.5:7b (未安装)(黄色)
|
||||||
|
- ✅ 显示警告提示:当前配置的模型未安装
|
||||||
|
- ✅ 提示前往配置页面重新选择
|
||||||
|
|
||||||
|
### 5. 正常配置测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 启动 Ollama 服务
|
||||||
|
2. 安装模型:ollama pull qwen2.5:7b
|
||||||
|
3. 在设置中选择该模型
|
||||||
|
4. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:Ollama 状态: 已连接(绿色)| 模型数量: 1 个(绿色)
|
||||||
|
- ✅ 当前模型: qwen2.5:7b (4.XX GB)(绿色)
|
||||||
|
- ✅ 不显示任何警告提示
|
||||||
|
- ✅ 侧边栏显示正常状态
|
||||||
|
|
||||||
|
### 6. 多个模型测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 安装多个模型:qwen2.5:7b, llama3:8b
|
||||||
|
2. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 底部状态栏显示:模型数量: 2 个
|
||||||
|
- ✅ 当前模型显示实际选中的模型信息
|
||||||
|
- ✅ 模型配置页面显示所有可用模型
|
||||||
|
|
||||||
|
### 7. 配置变更测试
|
||||||
|
|
||||||
|
**步骤:**
|
||||||
|
1. 从一个模型切换到另一个模型
|
||||||
|
2. 观察底部状态栏变化
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 切换后底部状态栏立即更新
|
||||||
|
- ✅ 如果新模型未安装,显示警告状态
|
||||||
|
- ✅ 如果新模型已安装,显示正常状态
|
||||||
|
|
||||||
|
## 🔄 状态刷新测试
|
||||||
|
|
||||||
|
### 手动刷新测试
|
||||||
|
**步骤:**
|
||||||
|
1. 点击底部"刷新状态"按钮
|
||||||
|
2. 观察 loading 状态
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 按钮显示加载动画
|
||||||
|
- ✅ 状态信息更新
|
||||||
|
- ✅ 如果期间状态变化,及时反映
|
||||||
|
|
||||||
|
### 自动刷新测试
|
||||||
|
**步骤:**
|
||||||
|
1. 启动应用后等待30秒
|
||||||
|
2. 观察 auto-refresh 机制
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 每30秒自动检查状态
|
||||||
|
- ✅ 状态变化时自动更新界面
|
||||||
|
- ✅ 无需手动操作
|
||||||
|
|
||||||
|
## 🎯 模型配置页面测试
|
||||||
|
|
||||||
|
### 页面加载测试
|
||||||
|
**步骤:**
|
||||||
|
1. 进入设置 > 模型页面
|
||||||
|
2. 观察初始状态
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 页面顶部显示服务状态卡片
|
||||||
|
- ✅ 如果已连接,自动显示已安装模型
|
||||||
|
- ✅ 表单自动加载当前配置
|
||||||
|
|
||||||
|
### 检测模型测试
|
||||||
|
**步骤:**
|
||||||
|
1. 点击"检测模型"按钮
|
||||||
|
2. 观察 loading 和结果
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 显示加载状态
|
||||||
|
- ✅ 检测完成后显示模型列表
|
||||||
|
- ✅ 如果当前模型未安装,自动清空选择
|
||||||
|
- ✅ 全局状态同步更新
|
||||||
|
|
||||||
|
### 保存配置测试
|
||||||
|
**步骤:**
|
||||||
|
1. 修改模型选择
|
||||||
|
2. 点击"保存配置"
|
||||||
|
3. 观察底部状态栏
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 配置保存成功提示
|
||||||
|
- ✅ 底部状态栏立即更新
|
||||||
|
- ✅ 全局状态同步更新
|
||||||
|
- ✅ 新配置生效
|
||||||
|
|
||||||
|
## 🚨 异常情况测试
|
||||||
|
|
||||||
|
### 网络断开测试
|
||||||
|
**步骤:**
|
||||||
|
1. 正常运行时断开网络
|
||||||
|
2. 尝试刷新状态
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 显示连接错误
|
||||||
|
- ✅ 不显示崩溃或白屏
|
||||||
|
- ✅ 提供重试机制
|
||||||
|
|
||||||
|
### 模型损坏测试
|
||||||
|
**步骤:**
|
||||||
|
1. 使用损坏的模型
|
||||||
|
2. 启动应用
|
||||||
|
|
||||||
|
**预期结果:**
|
||||||
|
- ✅ 系统识别模型问题
|
||||||
|
- ✅ 提示重新选择模型
|
||||||
|
- ✅ 不影响其他功能使用
|
||||||
|
|
||||||
|
## ✅ 功能完整性检查
|
||||||
|
|
||||||
|
- [ ] 启动时自动检查 Ollama 状态
|
||||||
|
- [ ] 启动时自动检查模型安装情况
|
||||||
|
- [ ] 底部状态栏实时显示准确信息
|
||||||
|
- [ ] 模型未配置时显示明确提示
|
||||||
|
- [ ] 模型未安装时显示警告状态
|
||||||
|
- [ ] 配置无效模型时提示用户
|
||||||
|
- [ ] 提供"前往配置"快捷入口
|
||||||
|
- [ ] 手动刷新状态功能正常
|
||||||
|
- [ ] 自动刷新状态机制工作
|
||||||
|
- [ ] 配置变更立即生效
|
||||||
|
- [ ] 错误处理用户友好
|
||||||
|
|
||||||
|
## 🎨 界面显示规则
|
||||||
|
|
||||||
|
### 状态颜色规则
|
||||||
|
- **绿色**: 状态正常,已配置且已安装
|
||||||
|
- **黄色**: 有问题但可用,如未安装模型
|
||||||
|
- **红色**: 严重问题,如未配置或服务未连接
|
||||||
|
- **灰色**: 服务未连接时的模型信息
|
||||||
|
|
||||||
|
### 文字提示规则
|
||||||
|
- **未配置模型**: "未配置模型" + 红色 + 前往配置按钮
|
||||||
|
- **未安装模型**: "模型名 (未安装)" + 黄色
|
||||||
|
- **服务未连接**: "模型名 (服务未连接)" + 灰色
|
||||||
|
- **正常状态**: "模型名 (大小)" + 绿色
|
||||||
|
|
||||||
|
通过以上测试,确保系统在各种边界情况下都能正确显示状态信息并提供用户友好的提示。
|
||||||
15
build/asset-manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"files": {
|
||||||
|
"main.css": "/static/css/main.18111be9.css",
|
||||||
|
"main.js": "/static/js/main.907ae9a2.js",
|
||||||
|
"static/js/453.d0dce72f.chunk.js": "/static/js/453.d0dce72f.chunk.js",
|
||||||
|
"index.html": "/index.html",
|
||||||
|
"main.18111be9.css.map": "/static/css/main.18111be9.css.map",
|
||||||
|
"main.907ae9a2.js.map": "/static/js/main.907ae9a2.js.map",
|
||||||
|
"453.d0dce72f.chunk.js.map": "/static/js/453.d0dce72f.chunk.js.map"
|
||||||
|
},
|
||||||
|
"entrypoints": [
|
||||||
|
"static/css/main.18111be9.css",
|
||||||
|
"static/js/main.907ae9a2.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
build/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
1
build/index.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>React App</title><script defer="defer" src="/static/js/main.907ae9a2.js"></script><link href="/static/css/main.18111be9.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||||
BIN
build/logo192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
build/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
build/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
build/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
2
build/static/css/main.18111be9.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin-animation{animation:spin 3s linear infinite}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.markdown-content{color:#333;line-height:1.6}.markdown-content h1,.markdown-content h2,.markdown-content h3,.markdown-content h4,.markdown-content h5,.markdown-content h6{font-weight:600;line-height:1.25;margin-bottom:16px;margin-top:24px}.markdown-content p{margin-bottom:16px}.markdown-content ol,.markdown-content ul{margin-bottom:16px;padding-left:2em}.markdown-content li{margin-bottom:4px}.markdown-content blockquote{border-left:.25em solid #dfe2e5;color:#6a737d;margin-bottom:16px;padding:0 1em}.markdown-content code{background-color:#1b1f230d;border-radius:3px;font-size:85%;margin:0;padding:.2em .4em}.markdown-content pre{background-color:#f6f8fa;border-radius:6px;font-size:85%;line-height:1.45;margin-bottom:16px;overflow:auto;padding:16px}
|
||||||
|
/*# sourceMappingURL=main.18111be9.css.map*/
|
||||||
1
build/static/css/main.18111be9.css.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/css/main.18111be9.css","mappings":"AAAA,gBACE,GACE,sBACF,CACA,GACE,uBACF,CACF,CAEA,gBACE,iCACF,CCXA,KAKE,kCAAmC,CACnC,iCAAkC,CAJlC,mIAEY,CAHZ,QAMF,CAEA,KACE,uEAEF,CAEA,kBAEE,UAAW,CADX,eAEF,CAEA,8HAQE,eAAgB,CAChB,gBAAiB,CAFjB,kBAAmB,CADnB,eAIF,CAEA,oBACE,kBACF,CAEA,0CAGE,kBAAmB,CADnB,gBAEF,CAEA,qBACE,iBACF,CAEA,6BAGE,+BAAiC,CADjC,aAAc,CAEd,kBAAmB,CAHnB,aAIF,CAEA,uBAIE,0BAAqC,CACrC,iBAAkB,CAFlB,aAAc,CADd,QAAS,CADT,iBAKF,CAEA,sBAKE,wBAAyB,CACzB,iBAAkB,CAHlB,aAAc,CACd,gBAAiB,CAGjB,kBAAmB,CALnB,aAAc,CADd,YAOF","sources":["components/MainLayout.css","App.css"],"sourcesContent":["@keyframes spin {\n from {\n transform: rotate(0deg);\n }\n to {\n transform: rotate(360deg);\n }\n}\n\n.spin-animation {\n animation: spin 3s linear infinite;\n}","body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n monospace;\n}\n\n.markdown-content {\n line-height: 1.6;\n color: #333;\n}\n\n.markdown-content h1,\n.markdown-content h2,\n.markdown-content h3,\n.markdown-content h4,\n.markdown-content h5,\n.markdown-content h6 {\n margin-top: 24px;\n margin-bottom: 16px;\n font-weight: 600;\n line-height: 1.25;\n}\n\n.markdown-content p {\n margin-bottom: 16px;\n}\n\n.markdown-content ul,\n.markdown-content ol {\n padding-left: 2em;\n margin-bottom: 16px;\n}\n\n.markdown-content li {\n margin-bottom: 4px;\n}\n\n.markdown-content blockquote {\n padding: 0 1em;\n color: #6a737d;\n border-left: 0.25em solid #dfe2e5;\n margin-bottom: 16px;\n}\n\n.markdown-content code {\n padding: 0.2em 0.4em;\n margin: 0;\n font-size: 85%;\n background-color: rgba(27,31,35,0.05);\n border-radius: 3px;\n}\n\n.markdown-content pre {\n padding: 16px;\n overflow: auto;\n font-size: 85%;\n line-height: 1.45;\n background-color: #f6f8fa;\n border-radius: 6px;\n margin-bottom: 16px;\n}\n"],"names":[],"sourceRoot":""}
|
||||||
2
build/static/js/453.d0dce72f.chunk.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"use strict";(self.webpackChunkai_novel=self.webpackChunkai_novel||[]).push([[453],{453(e,t,n){n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver(function(e){return e.getEntries().map(t)});return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",function(t){t.persisted&&e(t)},!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f(function(e){var t=e.timeStamp;v=t},!0)},l=function(){return v<0&&(v=p(),d(),s(function(){setTimeout(function(){v=p(),d()},0)})),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s(function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,n(!0)})})}))},h=!1,T=-1,y=function(e,t){h||(g(function(e){T=e.value}),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f(function(){p.takeRecords().map(v),n(!0)}),s(function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)}))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach(function(t){t(e)}),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach(function(t){return e(t,b,E)})},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},d=c("first-input",p);n=m(e,v,t),d&&f(function(){d.takeRecords().map(p),d.disconnect()},!0),d&&s(function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=p,o.push(a),S()})},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach(function(e){addEventListener(e,v,{once:!0,capture:!0})}),f(v,!0),s(function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)})})})}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",function(){return setTimeout(t,0)})}}}]);
|
||||||
|
//# sourceMappingURL=453.d0dce72f.chunk.js.map
|
||||||
1
build/static/js/453.d0dce72f.chunk.js.map
Normal file
3
build/static/js/main.907ae9a2.js
Normal file
176
build/static/js/main.907ae9a2.js.LICENSE.txt
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/*!
|
||||||
|
Copyright (c) 2018 Jed Watson.
|
||||||
|
Licensed under the MIT License (MIT), see
|
||||||
|
http://jedwatson.github.io/classnames
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-dom-client.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-is.production.min.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react-jsx-runtime.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* react.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @license React
|
||||||
|
* scheduler.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* react-router v7.13.1
|
||||||
|
*
|
||||||
|
* Copyright (c) Remix Software Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE.md file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @license MIT
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
|
|
||||||
|
/** */
|
||||||
1
build/static/js/main.907ae9a2.js.map
Normal file
20080
package-lock.json
generated
Normal file
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-novel",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/node": "^16.18.126",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/react-router-dom": "^5.3.3",
|
||||||
|
"antd": "^5.29.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
|
"ollama": "^0.6.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/marked": "^5.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/logo192.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
69
src/App.css
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3,
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
padding-left: 2em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
padding: 0 1em;
|
||||||
|
color: #6a737d;
|
||||||
|
border-left: 0.25em solid #dfe2e5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 85%;
|
||||||
|
background-color: rgba(27,31,35,0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 85%;
|
||||||
|
line-height: 1.45;
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
9
src/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
75
src/App.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { ConfigProvider, App as AntdApp, Spin } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { OllamaProvider } from './contexts/OllamaContext';
|
||||||
|
import MainLayout from './components/MainLayout';
|
||||||
|
import NovelList from './pages/NovelList';
|
||||||
|
import NovelDetail from './pages/NovelDetail';
|
||||||
|
import NovelGenerate from './pages/NovelGenerate';
|
||||||
|
import ModelSettings from './pages/ModelSettings';
|
||||||
|
import UserManual from './pages/UserManual';
|
||||||
|
import SkillsPage from './pages/SkillsPage';
|
||||||
|
import InitPage from './pages/InitPage';
|
||||||
|
import ConversationsPage from './pages/ConversationsPage';
|
||||||
|
import { indexedDBStorage } from './utils/indexedDB';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [isDBReady, setIsDBReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initDB = async () => {
|
||||||
|
try {
|
||||||
|
await indexedDBStorage.init();
|
||||||
|
setIsDBReady(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize IndexedDB:', error);
|
||||||
|
// 即使初始化失败也继续运行,因为 IndexedDB 操作会自动重试
|
||||||
|
setIsDBReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initDB();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isDBReady) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<div style={{ marginTop: '16px', color: '#666' }}>正在初始化数据库...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<AntdApp>
|
||||||
|
<OllamaProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<MainLayout />}>
|
||||||
|
<Route index element={<Navigate to="/novels" replace />} />
|
||||||
|
<Route path="novels" element={<NovelList />} />
|
||||||
|
<Route path="novels/:id" element={<NovelDetail />} />
|
||||||
|
<Route path="novels/:id/generate" element={<NovelGenerate />} />
|
||||||
|
<Route path="chat/conversations" element={<ConversationsPage />} />
|
||||||
|
<Route path="agents/skills" element={<SkillsPage />} />
|
||||||
|
<Route path="settings/model" element={<ModelSettings />} />
|
||||||
|
<Route path="settings/init" element={<InitPage />} />
|
||||||
|
<Route path="manual" element={<UserManual />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</OllamaProvider>
|
||||||
|
</AntdApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
63
src/components/Header.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout, Button, Space } from 'antd';
|
||||||
|
import { BookOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const { Header: AntHeader } = Layout;
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleUserManualClick = () => {
|
||||||
|
// 导航到使用手册页面
|
||||||
|
navigate('/manual');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntHeader style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
borderBottom: '1px solid #e8e8e8',
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '64px',
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 220,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#1890ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<BookOutlined />
|
||||||
|
<span>AI 小说创作系统</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<QuestionCircleOutlined />}
|
||||||
|
onClick={handleUserManualClick}
|
||||||
|
style={{
|
||||||
|
color: '#595959',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
使用手册
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</AntHeader>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
12
src/components/MainLayout.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin-animation {
|
||||||
|
animation: spin 3s linear infinite;
|
||||||
|
}
|
||||||
388
src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Layout, Menu, Alert, Space, Button } from 'antd';
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { BookOutlined, SettingOutlined, AppstoreOutlined, ReloadOutlined, RobotOutlined, ThunderboltOutlined, ClearOutlined, MessageOutlined } from '@ant-design/icons';
|
||||||
|
import { indexedDBStorage } from '../utils/indexedDB';
|
||||||
|
import { useOllama } from '../contexts/OllamaContext';
|
||||||
|
import Header from './Header';
|
||||||
|
import './MainLayout.css';
|
||||||
|
|
||||||
|
const { Sider, Content, Footer } = Layout;
|
||||||
|
|
||||||
|
const MainLayout: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [showAlert, setShowAlert] = useState<boolean>(true);
|
||||||
|
const [dbInitialized, setDbInitialized] = useState<boolean>(false);
|
||||||
|
const { status, refreshModels } = useOllama();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: '/novels',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
label: '小说管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/chat',
|
||||||
|
icon: <MessageOutlined />,
|
||||||
|
label: '聊天',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/chat/conversations',
|
||||||
|
icon: <MessageOutlined />,
|
||||||
|
label: '会话',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/agents',
|
||||||
|
icon: <RobotOutlined />,
|
||||||
|
label: '智能体',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/agents/skills',
|
||||||
|
icon: <ThunderboltOutlined />,
|
||||||
|
label: '技能',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '设置',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '/settings/model',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
label: '模型',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/settings/init',
|
||||||
|
icon: <ClearOutlined />,
|
||||||
|
label: '初始化',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
navigate(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化 IndexedDB
|
||||||
|
const initDB = async () => {
|
||||||
|
try {
|
||||||
|
await indexedDBStorage.init();
|
||||||
|
setDbInitialized(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize IndexedDB:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initDB();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const formatModelSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return '未知大小';
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
return `${gb.toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentModelInfo = () => {
|
||||||
|
// 如果没有配置模型,直接显示"暂未配置"
|
||||||
|
if (!status.currentModel) {
|
||||||
|
return '暂未配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型是否在本地已安装列表中
|
||||||
|
const model = status.availableModels.find(m => m.name === status.currentModel);
|
||||||
|
if (!model) {
|
||||||
|
// 模型未安装,只显示模型名称
|
||||||
|
return status.currentModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型已安装且可用,显示详细信息
|
||||||
|
return `${status.currentModel} (${formatModelSize(model.size)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentModelStatus = () => {
|
||||||
|
// 返回当前模型的状态类型
|
||||||
|
if (!status.currentModel) {
|
||||||
|
return 'error'; // 未配置
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.isConnected) {
|
||||||
|
return 'warning'; // 服务未连接
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = status.availableModels.find(m => m.name === status.currentModel);
|
||||||
|
if (!model) {
|
||||||
|
return 'warning'; // 模型未安装
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'success'; // 正常
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh', background: '#f5f7fa' }}>
|
||||||
|
<Header />
|
||||||
|
<Sider
|
||||||
|
width={220}
|
||||||
|
style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
borderRight: '1px solid #e8e8e8',
|
||||||
|
height: '100vh',
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
boxShadow: '2px 0 8px rgba(0,0,0,0.06)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '64px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
padding: '0 16px',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<RobotOutlined
|
||||||
|
style={{
|
||||||
|
color: '#1890ff',
|
||||||
|
fontSize: '20px'
|
||||||
|
}}
|
||||||
|
className="spin-animation"
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
color: '#1890ff',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: 'center',
|
||||||
|
letterSpacing: '0.5px'
|
||||||
|
}}>
|
||||||
|
AI 小说创作
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
style={{
|
||||||
|
borderRight: 0,
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '14px',
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingBottom: '50px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 底部品牌信息 - 悬浮固定 */}
|
||||||
|
<div
|
||||||
|
onClick={() => window.open('https://juejin.cn/user/3634340804698010', '_blank')}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '12px 16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
borderTop: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
zIndex: 10,
|
||||||
|
boxShadow: '0 -2px 8px rgba(0,0,0,0.05)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f0f9ff';
|
||||||
|
e.currentTarget.style.boxShadow = '0 -2px 12px rgba(24, 144, 255, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#fafafa';
|
||||||
|
e.currentTarget.style.boxShadow = '0 -2px 8px rgba(0,0,0,0.05)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}>
|
||||||
|
<div>代码老中医出品</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#bfbfbf',
|
||||||
|
marginTop: '2px'
|
||||||
|
}}>
|
||||||
|
v0.0.1
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
<Layout style={{ marginLeft: 220, marginTop: '64px' }}>
|
||||||
|
<Content style={{
|
||||||
|
padding: '20px',
|
||||||
|
background: '#f5f7fa',
|
||||||
|
minHeight: 'calc(100vh - 184px)'
|
||||||
|
}}>
|
||||||
|
{!dbInitialized && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div>正在初始化数据库...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dbInitialized && (!status.isConnected || status.modelCount === 0 || getCurrentModelStatus() === 'error') && showAlert && !location.pathname.startsWith('/settings') && (
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
!status.isConnected ? 'Ollama 服务未连接' :
|
||||||
|
status.modelCount === 0 ? '未检测到 AI 模型' :
|
||||||
|
'模型暂未配置'
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
!status.isConnected
|
||||||
|
? '请确保 Ollama 服务正在运行。在终端中执行命令:ollama serve'
|
||||||
|
: status.modelCount === 0
|
||||||
|
? '请先安装 AI 模型。在终端中执行命令:ollama pull qwen3:8b'
|
||||||
|
: '请前往配置页面选择并配置可用模型'
|
||||||
|
}
|
||||||
|
type={!status.isConnected ? 'error' : 'warning'}
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
onClose={() => setShowAlert(false)}
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings/model')}
|
||||||
|
style={{
|
||||||
|
color: '#1890ff',
|
||||||
|
textDecoration: 'none',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
font: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
前往配置
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
minHeight: 'calc(100vh - 264px)',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.08)'
|
||||||
|
}}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Footer style={{
|
||||||
|
background: 'white',
|
||||||
|
borderTop: '1px solid #f0f0f0',
|
||||||
|
padding: '8px 20px',
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
left: 220,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
height: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
<Space size={20}>
|
||||||
|
<Space size={6}>
|
||||||
|
<span style={{ color: '#8c8c8c' }}>Ollama 状态:</span>
|
||||||
|
<span style={{
|
||||||
|
color: status.isConnected ? '#52c41a' : '#ff4d4f',
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
{status.isConnected ? '已连接' : '未连接'}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={6}>
|
||||||
|
<span style={{ color: '#8c8c8c' }}>模型数量:</span>
|
||||||
|
<span style={{
|
||||||
|
color: status.modelCount > 0 ? '#52c41a' : '#faad14',
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
{status.modelCount} 个
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space size={6}>
|
||||||
|
<span style={{ color: '#8c8c8c' }}>当前模型:</span>
|
||||||
|
<span style={{
|
||||||
|
color: getCurrentModelStatus() === 'success' ? '#52c41a' :
|
||||||
|
getCurrentModelStatus() === 'warning' ? '#faad14' : '#ff4d4f',
|
||||||
|
fontWeight: 500,
|
||||||
|
maxWidth: '300px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{getCurrentModelInfo()}
|
||||||
|
</span>
|
||||||
|
{getCurrentModelStatus() === 'error' && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings/model')}
|
||||||
|
style={{
|
||||||
|
background: '#ff4d4f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
前往配置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={refreshModels}
|
||||||
|
loading={status.isLoading}
|
||||||
|
style={{ color: '#1890ff' }}
|
||||||
|
>
|
||||||
|
刷新状态
|
||||||
|
</Button>
|
||||||
|
</Footer>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
172
src/contexts/OllamaContext.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { OllamaService } from '../utils/ollama';
|
||||||
|
import { storage } from '../utils/indexedDB';
|
||||||
|
import { SystemConfig } from '../types';
|
||||||
|
|
||||||
|
interface OllamaStatus {
|
||||||
|
isConnected: boolean;
|
||||||
|
modelCount: number;
|
||||||
|
currentModel: string;
|
||||||
|
availableModels: any[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
config: SystemConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaContextType {
|
||||||
|
status: OllamaStatus;
|
||||||
|
checkStatus: () => Promise<void>;
|
||||||
|
refreshModels: () => Promise<void>;
|
||||||
|
chat: (message: string, context: string) => Promise<string>;
|
||||||
|
isReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OllamaContext = createContext<OllamaContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const OllamaProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [status, setStatus] = useState<OllamaStatus>({
|
||||||
|
isConnected: false,
|
||||||
|
modelCount: 0,
|
||||||
|
currentModel: '',
|
||||||
|
availableModels: [],
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
config: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const getService = async (): Promise<OllamaService | null> => {
|
||||||
|
try {
|
||||||
|
// 优先从 localStorage 读取配置,如果没有则从 IndexedDB 读取
|
||||||
|
let config: SystemConfig;
|
||||||
|
const localConfigData = localStorage.getItem('ai_system_config');
|
||||||
|
if (localConfigData) {
|
||||||
|
config = JSON.parse(localConfigData) as SystemConfig;
|
||||||
|
} else {
|
||||||
|
config = await storage.getSystemConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.model || !config.ollamaUrl) {
|
||||||
|
setStatus(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: '请先在设置中配置AI模型'
|
||||||
|
}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OllamaService(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取AI服务失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
setStatus(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 优先从 localStorage 读取配置,如果没有则从 IndexedDB 读取
|
||||||
|
let config: SystemConfig;
|
||||||
|
const localConfigData = localStorage.getItem('ai_system_config');
|
||||||
|
if (localConfigData) {
|
||||||
|
config = JSON.parse(localConfigData) as SystemConfig;
|
||||||
|
} else {
|
||||||
|
config = await storage.getSystemConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有配置模型,设置为空字符串
|
||||||
|
if (!config.model) {
|
||||||
|
config.model = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(prev => ({ ...prev, config }));
|
||||||
|
|
||||||
|
const ollamaService = new OllamaService(config);
|
||||||
|
|
||||||
|
// 检查连接状态
|
||||||
|
const isConnected = await ollamaService.testConnection();
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
setStatus({
|
||||||
|
isConnected: false,
|
||||||
|
modelCount: 0,
|
||||||
|
currentModel: config.model || '',
|
||||||
|
availableModels: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: 'Ollama 服务未连接',
|
||||||
|
config
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型列表
|
||||||
|
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||||
|
|
||||||
|
// 验证当前配置的模型是否可用
|
||||||
|
const currentModel = config.model || '';
|
||||||
|
const isCurrentModelValid = currentModel && models.some(m => m.name === currentModel);
|
||||||
|
|
||||||
|
setStatus({
|
||||||
|
isConnected: true,
|
||||||
|
modelCount: models.length,
|
||||||
|
currentModel: currentModel,
|
||||||
|
availableModels: models,
|
||||||
|
isLoading: false,
|
||||||
|
error: !isCurrentModelValid && currentModel ? '当前配置的模型未安装' : null,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({
|
||||||
|
isConnected: false,
|
||||||
|
modelCount: 0,
|
||||||
|
currentModel: '',
|
||||||
|
availableModels: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: '状态检查失败',
|
||||||
|
config: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshModels = async () => {
|
||||||
|
await checkStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const chat = async (message: string, context: string): Promise<string> => {
|
||||||
|
const service = await getService();
|
||||||
|
if (!service) {
|
||||||
|
throw new Error('AI服务未配置或连接失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.config) {
|
||||||
|
throw new Error('请先配置AI模型');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await service.chat(message, context);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const isReady: boolean = status.isConnected && !!status.config && !!status.config.model;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
|
||||||
|
// 每 30 秒自动检查一次状态
|
||||||
|
const interval = setInterval(checkStatus, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OllamaContext.Provider value={{ status, checkStatus, refreshModels, chat, isReady }}>
|
||||||
|
{children}
|
||||||
|
</OllamaContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOllama = () => {
|
||||||
|
const context = useContext(OllamaContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useOllama must be used within an OllamaProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
13
src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
19
src/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById('root') as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
845
src/pages/ConversationsPage.tsx
Normal file
@@ -0,0 +1,845 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
List,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
Dropdown,
|
||||||
|
Popconfirm,
|
||||||
|
Alert
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
SendOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusSquareOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Sider, Content } = Layout;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Conversation {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
botName: string;
|
||||||
|
botType: 'feishu' | 'other';
|
||||||
|
lastMessage: string;
|
||||||
|
timestamp: string;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'feishu' | 'other';
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConversationsPage: React.FC = () => {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
|
||||||
|
const [messageInput, setMessageInput] = useState('');
|
||||||
|
const [botConfigs, setBotConfigs] = useState<BotConfig[]>([]);
|
||||||
|
const [showBotModal, setShowBotModal] = useState(false);
|
||||||
|
const [showConversationModal, setShowConversationModal] = useState(false);
|
||||||
|
const [editingBot, setEditingBot] = useState<BotConfig | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [conversationForm] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
// 加载飞书机器人配置
|
||||||
|
const savedBots = localStorage.getItem('botConfigs');
|
||||||
|
if (savedBots) {
|
||||||
|
setBotConfigs(JSON.parse(savedBots));
|
||||||
|
} else {
|
||||||
|
// 默认配置示例
|
||||||
|
const defaultBots: BotConfig[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '助手机器人',
|
||||||
|
type: 'feishu',
|
||||||
|
appId: 'cli_xxxxxxxxxxxxx',
|
||||||
|
appSecret: 'xxxxxxxxxxxxx',
|
||||||
|
description: '用于日常事务处理和提醒'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
setBotConfigs(defaultBots);
|
||||||
|
localStorage.setItem('botConfigs', JSON.stringify(defaultBots));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载会话列表
|
||||||
|
const savedConversations = localStorage.getItem('conversations');
|
||||||
|
if (savedConversations) {
|
||||||
|
setConversations(JSON.parse(savedConversations));
|
||||||
|
} else {
|
||||||
|
// 默认会话示例
|
||||||
|
const defaultConversations: Conversation[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: '欢迎使用飞书机器人',
|
||||||
|
botName: '助手机器人',
|
||||||
|
botType: 'feishu',
|
||||||
|
lastMessage: '你好!我是飞书助手机器人,有什么可以帮助您的吗?',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
content: '你好!我是飞书助手机器人,有什么可以帮助您的吗?',
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
setConversations(defaultConversations);
|
||||||
|
localStorage.setItem('conversations', JSON.stringify(defaultConversations));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = async () => {
|
||||||
|
if (!messageInput.trim() || !selectedConversation) return;
|
||||||
|
|
||||||
|
const newMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
content: messageInput,
|
||||||
|
role: 'user',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedConversation = {
|
||||||
|
...selectedConversation,
|
||||||
|
messages: [...selectedConversation.messages, newMessage],
|
||||||
|
lastMessage: messageInput,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedConversation(updatedConversation);
|
||||||
|
setConversations(conversations.map(conv =>
|
||||||
|
conv.id === selectedConversation.id ? updatedConversation : conv
|
||||||
|
));
|
||||||
|
|
||||||
|
// 保存到本地存储
|
||||||
|
localStorage.setItem('conversations', JSON.stringify(
|
||||||
|
conversations.map(conv => conv.id === selectedConversation.id ? updatedConversation : conv)
|
||||||
|
));
|
||||||
|
|
||||||
|
setMessageInput('');
|
||||||
|
|
||||||
|
// 模拟飞书应用回复
|
||||||
|
setTimeout(() => {
|
||||||
|
const botReply: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
content: `收到您的消息:"${messageInput}"。这是一个模拟的飞书应用回复。在实际使用中,这里会调用飞书自建应用API来发送消息。`,
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedWithReply = {
|
||||||
|
...updatedConversation,
|
||||||
|
messages: [...updatedConversation.messages, botReply],
|
||||||
|
lastMessage: botReply.content,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedConversation(updatedWithReply);
|
||||||
|
setConversations(conversations.map(conv =>
|
||||||
|
conv.id === selectedConversation.id ? updatedWithReply : conv
|
||||||
|
));
|
||||||
|
|
||||||
|
localStorage.setItem('conversations', JSON.stringify(
|
||||||
|
conversations.map(conv => conv.id === selectedConversation.id ? updatedWithReply : conv)
|
||||||
|
));
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleSubmitConversation = () => {
|
||||||
|
conversationForm.validateFields().then(values => {
|
||||||
|
const selectedBot = botConfigs.find(bot => bot.id === values.botId);
|
||||||
|
if (!selectedBot) return;
|
||||||
|
|
||||||
|
const newConversation: Conversation = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: values.title,
|
||||||
|
botName: selectedBot.name,
|
||||||
|
botType: selectedBot.type,
|
||||||
|
lastMessage: '新会话已创建',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
messages: []
|
||||||
|
};
|
||||||
|
|
||||||
|
setConversations([newConversation, ...conversations]);
|
||||||
|
localStorage.setItem('conversations', JSON.stringify([newConversation, ...conversations]));
|
||||||
|
setShowConversationModal(false);
|
||||||
|
conversationForm.resetFields();
|
||||||
|
setSelectedConversation(newConversation);
|
||||||
|
message.success('会话创建成功');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddBot = () => {
|
||||||
|
setEditingBot(null);
|
||||||
|
form.resetFields();
|
||||||
|
setShowBotModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditBot = (bot: BotConfig) => {
|
||||||
|
setEditingBot(bot);
|
||||||
|
form.setFieldsValue(bot);
|
||||||
|
setShowBotModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBot = (botId: string) => {
|
||||||
|
const updatedBots = botConfigs.filter(bot => bot.id !== botId);
|
||||||
|
setBotConfigs(updatedBots);
|
||||||
|
localStorage.setItem('botConfigs', JSON.stringify(updatedBots));
|
||||||
|
message.success('机器人配置已删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitBot = () => {
|
||||||
|
form.validateFields().then(values => {
|
||||||
|
if (editingBot) {
|
||||||
|
// 编辑模式
|
||||||
|
const updatedBot: BotConfig = {
|
||||||
|
...editingBot,
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
appId: values.appId,
|
||||||
|
appSecret: values.appSecret,
|
||||||
|
description: values.description
|
||||||
|
};
|
||||||
|
const updatedBots = botConfigs.map(bot =>
|
||||||
|
bot.id === editingBot.id ? updatedBot : bot
|
||||||
|
);
|
||||||
|
setBotConfigs(updatedBots);
|
||||||
|
localStorage.setItem('botConfigs', JSON.stringify(updatedBots));
|
||||||
|
message.success('飞书应用配置已更新');
|
||||||
|
} else {
|
||||||
|
// 新增模式
|
||||||
|
const newBot: BotConfig = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
appId: values.appId,
|
||||||
|
appSecret: values.appSecret,
|
||||||
|
description: values.description
|
||||||
|
};
|
||||||
|
setBotConfigs([...botConfigs, newBot]);
|
||||||
|
localStorage.setItem('botConfigs', JSON.stringify([...botConfigs, newBot]));
|
||||||
|
message.success('飞书应用配置已添加');
|
||||||
|
}
|
||||||
|
setShowBotModal(false);
|
||||||
|
form.resetFields();
|
||||||
|
setEditingBot(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConversation = (conversationId: string) => {
|
||||||
|
const updatedConversations = conversations.filter(conv => conv.id !== conversationId);
|
||||||
|
setConversations(updatedConversations);
|
||||||
|
localStorage.setItem('conversations', JSON.stringify(updatedConversations));
|
||||||
|
if (selectedConversation?.id === conversationId) {
|
||||||
|
setSelectedConversation(null);
|
||||||
|
}
|
||||||
|
message.success('会话已删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBotItems = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'add',
|
||||||
|
label: (
|
||||||
|
<div onClick={() => handleAddBot()}>
|
||||||
|
<PlusSquareOutlined style={{ marginRight: '8px' }} />
|
||||||
|
添加飞书应用
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider' as const,
|
||||||
|
},
|
||||||
|
...botConfigs.map(bot => ({
|
||||||
|
key: bot.id,
|
||||||
|
label: (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>
|
||||||
|
<RobotOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
|
||||||
|
{bot.name}
|
||||||
|
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'} style={{ marginLeft: '8px' }}>
|
||||||
|
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
<Space size={4}>
|
||||||
|
<EditOutlined
|
||||||
|
style={{ color: '#52c41a', cursor: 'pointer' }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditBot(bot); }}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个飞书应用配置吗?"
|
||||||
|
onConfirm={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
handleDeleteBot(bot.id);
|
||||||
|
}}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<DeleteOutlined
|
||||||
|
style={{ color: '#ff4d4f', cursor: 'pointer' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 'calc(100vh - 184px)' }}>
|
||||||
|
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<MessageOutlined style={{ color: '#1890ff' }} />
|
||||||
|
飞书机器人会话
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||||
|
与飞书自建应用进行对话,指挥AI助手完成各种任务
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: getBotItems() }}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<Button icon={<SettingOutlined />}>
|
||||||
|
飞书应用配置 ({botConfigs.length})
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Layout style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
height: 'calc(100% - 80px)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* 左侧会话列表 */}
|
||||||
|
<Sider
|
||||||
|
width={320}
|
||||||
|
style={{
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRight: '1px solid #f0f0f0',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="搜索会话..."
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 飞书应用列表 */}
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||||
|
飞书应用
|
||||||
|
</div>
|
||||||
|
{botConfigs.length > 0 ? (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
||||||
|
{botConfigs.map(bot => {
|
||||||
|
const hasConversation = conversations.some(conv => conv.botName === bot.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bot.id}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasConversation) {
|
||||||
|
const existingConv = conversations.find(conv => conv.botName === bot.name);
|
||||||
|
if (existingConv) {
|
||||||
|
setSelectedConversation(existingConv);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建新会话
|
||||||
|
const newConversation: Conversation = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: bot.name,
|
||||||
|
botName: bot.name,
|
||||||
|
botType: bot.type,
|
||||||
|
lastMessage: '新会话已创建',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
messages: []
|
||||||
|
};
|
||||||
|
setConversations([newConversation, ...conversations]);
|
||||||
|
localStorage.setItem('conversations', JSON.stringify([newConversation, ...conversations]));
|
||||||
|
setSelectedConversation(newConversation);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: selectedConversation?.botName === bot.name ? '#e6f7ff' : 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedConversation?.botName !== bot.name) {
|
||||||
|
e.currentTarget.style.background = '#f5f5f5';
|
||||||
|
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedConversation?.botName !== bot.name) {
|
||||||
|
e.currentTarget.style.background = 'white';
|
||||||
|
e.currentTarget.style.borderColor = '#f0f0f0';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
style={{ backgroundColor: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Text strong style={{ fontSize: '14px' }}>
|
||||||
|
{bot.name}
|
||||||
|
</Text>
|
||||||
|
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'}>
|
||||||
|
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||||||
|
{hasConversation ? '点击继续对话' : '点击开始对话'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0', color: '#999' }}>
|
||||||
|
<Text type="secondary">暂无飞书应用</Text>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Button type="link" size="small" onClick={handleAddBot}>
|
||||||
|
添加飞书应用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 会话列表 */}
|
||||||
|
<div style={{ padding: '12px 16px 0', flex: 1, overflowY: 'auto' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||||
|
会话列表
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
dataSource={conversations}
|
||||||
|
renderItem={(conversation) => (
|
||||||
|
<List.Item
|
||||||
|
key={conversation.id}
|
||||||
|
onClick={() => setSelectedConversation(conversation)}
|
||||||
|
style={{
|
||||||
|
padding: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selectedConversation?.id === conversation.id ? '#e6f7ff' : 'transparent',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedConversation?.id !== conversation.id) {
|
||||||
|
e.currentTarget.style.background = '#f5f5f5';
|
||||||
|
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedConversation?.id !== conversation.id) {
|
||||||
|
e.currentTarget.style.background = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = '#f0f0f0';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<Avatar
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
style={{ backgroundColor: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text strong ellipsis style={{ maxWidth: '140px' }}>
|
||||||
|
{conversation.title}
|
||||||
|
</Text>
|
||||||
|
<Tag color={conversation.botType === 'feishu' ? 'blue' : 'green'}>
|
||||||
|
{conversation.botType === 'feishu' ? '飞书' : '其他'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<Text ellipsis style={{ fontSize: '11px', color: '#666' }}>
|
||||||
|
{conversation.lastMessage}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: '10px', color: '#999', display: 'block', marginTop: '2px' }}>
|
||||||
|
{new Date(conversation.timestamp).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Sider>
|
||||||
|
|
||||||
|
{/* 右侧会话内容 */}
|
||||||
|
<Content style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{selectedConversation ? (
|
||||||
|
<>
|
||||||
|
{/* 会话头部 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Space>
|
||||||
|
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
{selectedConversation.title}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
飞书机器人:{selectedConversation.botName}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个会话吗?"
|
||||||
|
onConfirm={() => handleDeleteConversation(selectedConversation.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>
|
||||||
|
删除会话
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '24px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'white'
|
||||||
|
}}>
|
||||||
|
{selectedConversation.messages.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '100px 0',
|
||||||
|
color: '#999'
|
||||||
|
}}>
|
||||||
|
<MessageOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||||
|
<div>暂无消息,开始与机器人对话吧!</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||||
|
{selectedConversation.messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: message.role === 'user' ? 'flex-end' : 'flex-start',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '70%',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
}}>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<Avatar icon={<RobotOutlined />} style={{ backgroundColor: '#1890ff' }} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
|
||||||
|
{selectedConversation.botName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
background: message.role === 'user' ? '#1890ff' : '#f5f5f5',
|
||||||
|
color: message.role === 'user' ? 'white' : 'black',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginTop: '4px',
|
||||||
|
wordBreak: 'break-word'
|
||||||
|
}}>
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: '11px', marginTop: '4px', display: 'block' }}>
|
||||||
|
{new Date(message.timestamp).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{message.role === 'user' && (
|
||||||
|
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#52c41a' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息输入区 */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
borderTop: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa'
|
||||||
|
}}>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<TextArea
|
||||||
|
value={messageInput}
|
||||||
|
onChange={(e) => setMessageInput(e.target.value)}
|
||||||
|
placeholder="输入消息,按Enter发送,Shift+Enter换行"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
onPressEnter={(e) => {
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'white'
|
||||||
|
}}>
|
||||||
|
<div style={{ textAlign: 'center', color: '#999' }}>
|
||||||
|
<MessageOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||||
|
<div style={{ fontSize: '16px', marginBottom: '16px' }}>
|
||||||
|
选择左侧的飞书应用开始对话
|
||||||
|
</div>
|
||||||
|
{botConfigs.length === 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>或先添加飞书应用配置</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddBot}
|
||||||
|
>
|
||||||
|
添加飞书应用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
{/* 飞书应用配置弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined style={{ color: '#1890ff' }} />
|
||||||
|
{editingBot ? '编辑飞书应用配置' : '添加飞书自建应用'}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={showBotModal}
|
||||||
|
onOk={handleSubmitBot}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowBotModal(false);
|
||||||
|
form.resetFields();
|
||||||
|
setEditingBot(null);
|
||||||
|
}}
|
||||||
|
width={700}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
message="飞书自建应用配置说明"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>1. 在飞书开放平台创建自建应用</p>
|
||||||
|
<p>2. 在「凭证与基础信息」页面复制 App ID 和 App Secret</p>
|
||||||
|
<p>3. 在「权限管理」配置必要权限(如 im:message、im:resource 等)</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: '24px' }}
|
||||||
|
initialValues={{
|
||||||
|
type: 'feishu'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="应用名称"
|
||||||
|
rules={[{ required: true, message: '请输入应用名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:AI助手" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="type"
|
||||||
|
label="应用类型"
|
||||||
|
rules={[{ required: true, message: '请选择应用类型' }]}
|
||||||
|
>
|
||||||
|
<Select>
|
||||||
|
<Select.Option value="feishu">飞书自建应用</Select.Option>
|
||||||
|
<Select.Option value="other">其他应用</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="appId"
|
||||||
|
label="App ID"
|
||||||
|
rules={[{ required: true, message: '请输入 App ID' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="cli_xxxxxxxxxxxxx"
|
||||||
|
prefix={<span style={{ color: '#999' }}>App ID:</span>}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="appSecret"
|
||||||
|
label="App Secret"
|
||||||
|
rules={[{ required: true, message: '请输入 App Secret' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="xxxxxxxxxxxxx"
|
||||||
|
prefix={<span style={{ color: '#999' }}>Secret:</span>}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="应用描述"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="描述应用的用途和功能"
|
||||||
|
rows={3}
|
||||||
|
showCount
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 新建会话弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<MessageOutlined style={{ color: '#1890ff' }} />
|
||||||
|
新建会话
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={showConversationModal}
|
||||||
|
onOk={handleSubmitConversation}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConversationModal(false);
|
||||||
|
conversationForm.resetFields();
|
||||||
|
}}
|
||||||
|
okText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={conversationForm}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: '24px' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="会话标题"
|
||||||
|
rules={[{ required: true, message: '请输入会话标题' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:工作安排助手" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="botId"
|
||||||
|
label="选择飞书应用"
|
||||||
|
rules={[{ required: true, message: '请选择飞书应用' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择要使用的飞书应用">
|
||||||
|
{botConfigs.map(bot => (
|
||||||
|
<Select.Option key={bot.id} value={bot.id}>
|
||||||
|
<Space>
|
||||||
|
<RobotOutlined />
|
||||||
|
{bot.name}
|
||||||
|
<Tag color={bot.type === 'feishu' ? 'blue' : 'green'}>
|
||||||
|
{bot.type === 'feishu' ? '飞书' : '其他'}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConversationsPage;
|
||||||
816
src/pages/InitPage.tsx
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Alert,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
message,
|
||||||
|
Divider,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Progress,
|
||||||
|
Steps,
|
||||||
|
Descriptions,
|
||||||
|
Input
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ClearOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
FundOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const InitPage: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [initStatus, setInitStatus] = useState<{
|
||||||
|
step: number;
|
||||||
|
message: string;
|
||||||
|
status: 'process' | 'finish' | 'error' | 'wait';
|
||||||
|
}>({ step: 0, message: '等待初始化...', status: 'wait' });
|
||||||
|
const [dbStats, setDbStats] = useState({
|
||||||
|
novels: 0,
|
||||||
|
chapters: 0,
|
||||||
|
settings: 0
|
||||||
|
});
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [isFirstRun, setIsFirstRun] = useState(false);
|
||||||
|
const [initMode, setInitMode] = useState<'reset' | 'setup'>('reset');
|
||||||
|
|
||||||
|
const getDbStats = async () => {
|
||||||
|
try {
|
||||||
|
const { storage } = await import('../utils/indexedDB');
|
||||||
|
const novels = await storage.getNovels();
|
||||||
|
const settings = localStorage.getItem('ollamaSettings');
|
||||||
|
|
||||||
|
let totalChapters = 0;
|
||||||
|
for (const novel of novels) {
|
||||||
|
const chapters = await storage.getChapters(novel.id);
|
||||||
|
totalChapters += chapters.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDbStats({
|
||||||
|
novels: novels.length,
|
||||||
|
chapters: totalChapters,
|
||||||
|
settings: settings ? 1 : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否是首次运行
|
||||||
|
const hasRunBefore = localStorage.getItem('aiNovelInitialized');
|
||||||
|
const isFirstRunDetected = !hasRunBefore && novels.length === 0 && !settings;
|
||||||
|
setIsFirstRun(isFirstRunDetected);
|
||||||
|
|
||||||
|
if (isFirstRunDetected) {
|
||||||
|
setInitMode('setup');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取数据库统计失败:', error);
|
||||||
|
// 如果获取失败,可能是首次运行
|
||||||
|
const hasRunBefore = localStorage.getItem('aiNovelInitialized');
|
||||||
|
if (!hasRunBefore) {
|
||||||
|
setIsFirstRun(true);
|
||||||
|
setInitMode('setup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDbStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInit = async () => {
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmInit = async () => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setLoading(true);
|
||||||
|
setCurrentStep(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (initMode === 'setup') {
|
||||||
|
// 首次安装模式
|
||||||
|
await firstTimeSetup();
|
||||||
|
} else {
|
||||||
|
// 重置模式
|
||||||
|
await systemReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记系统已初始化
|
||||||
|
localStorage.setItem('aiNovelInitialized', new Date().toISOString());
|
||||||
|
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
message.success(initMode === 'setup' ? '系统配置完成!' : '系统重置完成!');
|
||||||
|
|
||||||
|
// 重新获取统计数据
|
||||||
|
setTimeout(() => {
|
||||||
|
getDbStats();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
setInitStatus({
|
||||||
|
step: currentStep + 1,
|
||||||
|
message: `初始化失败: ${error.message}`,
|
||||||
|
status: 'error'
|
||||||
|
});
|
||||||
|
message.error('系统初始化失败,请查看错误信息');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstTimeSetup = async () => {
|
||||||
|
const setupSteps = [
|
||||||
|
// 步骤1:初始化IndexedDB
|
||||||
|
{
|
||||||
|
step: 0,
|
||||||
|
message: '正在初始化IndexedDB数据库...',
|
||||||
|
action: async () => {
|
||||||
|
await reinitializeIndexedDB();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 步骤2:创建默认配置
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
message: '正在创建默认配置...',
|
||||||
|
action: async () => {
|
||||||
|
restoreDefaultSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 步骤3:初始化技能库
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
message: '正在初始化技能库...',
|
||||||
|
action: async () => {
|
||||||
|
await initializeSkills();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 步骤4:配置系统参数
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
message: '正在配置系统参数...',
|
||||||
|
action: async () => {
|
||||||
|
await configureSystem();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 步骤5:验证系统状态
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
message: '正在验证系统状态...',
|
||||||
|
action: async () => {
|
||||||
|
await verifySystem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { step, message, action } of setupSteps) {
|
||||||
|
setCurrentStep(step);
|
||||||
|
setInitStatus({ step: step + 1, message, status: 'process' });
|
||||||
|
|
||||||
|
await action();
|
||||||
|
|
||||||
|
setInitStatus({
|
||||||
|
step: step + 1,
|
||||||
|
message: `${message.replace('正在', '')}完成`,
|
||||||
|
status: 'finish'
|
||||||
|
});
|
||||||
|
await sleep(800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemReset = async () => {
|
||||||
|
// 步骤1:清除IndexedDB数据
|
||||||
|
setCurrentStep(0);
|
||||||
|
setInitStatus({ step: 1, message: '正在清除IndexedDB数据...', status: 'process' });
|
||||||
|
|
||||||
|
await clearIndexedDB();
|
||||||
|
|
||||||
|
setInitStatus({ step: 1, message: 'IndexedDB数据清除完成', status: 'finish' });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// 步骤2:清除本地存储配置
|
||||||
|
setCurrentStep(1);
|
||||||
|
setInitStatus({ step: 2, message: '正在清除本地存储配置...', status: 'process' });
|
||||||
|
|
||||||
|
clearLocalStorage();
|
||||||
|
|
||||||
|
setInitStatus({ step: 2, message: '本地存储配置清除完成', status: 'finish' });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// 步骤3:重新初始化IndexedDB
|
||||||
|
setCurrentStep(2);
|
||||||
|
setInitStatus({ step: 3, message: '正在重新初始化IndexedDB...', status: 'process' });
|
||||||
|
|
||||||
|
await reinitializeIndexedDB();
|
||||||
|
|
||||||
|
setInitStatus({ step: 3, message: 'IndexedDB重新初始化完成', status: 'finish' });
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
// 步骤4:恢复默认配置
|
||||||
|
setCurrentStep(3);
|
||||||
|
setInitStatus({ step: 4, message: '正在恢复默认配置...', status: 'process' });
|
||||||
|
|
||||||
|
restoreDefaultSettings();
|
||||||
|
|
||||||
|
setInitStatus({ step: 4, message: '默认配置恢复完成', status: 'finish' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearIndexedDB = async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.deleteDatabase('AINovelDatabase');
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('IndexedDB删除成功');
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('IndexedDB删除失败');
|
||||||
|
reject(new Error('IndexedDB删除失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onblocked = () => {
|
||||||
|
console.warn('IndexedDB删除被阻止,正在重试...');
|
||||||
|
setTimeout(() => {
|
||||||
|
clearIndexedDB().then(resolve).catch(reject);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLocalStorage = () => {
|
||||||
|
// 清除所有Ollama相关的设置
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && (key.includes('ollama') || key.includes('model') || key.includes('settings'))) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||||
|
console.log('清除了', keysToRemove.length, '个本地存储项');
|
||||||
|
};
|
||||||
|
|
||||||
|
const reinitializeIndexedDB = async () => {
|
||||||
|
const { indexedDBStorage } = await import('../utils/indexedDB');
|
||||||
|
await indexedDBStorage.init();
|
||||||
|
console.log('IndexedDB重新初始化完成');
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreDefaultSettings = () => {
|
||||||
|
// 恢复默认的Ollama设置
|
||||||
|
const defaultSettings = {
|
||||||
|
apiUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('ollamaSettings', JSON.stringify(defaultSettings));
|
||||||
|
console.log('默认配置恢复完成');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSkills = async () => {
|
||||||
|
// 初始化默认技能库
|
||||||
|
const defaultSkills = [
|
||||||
|
{
|
||||||
|
id: 'skill_1',
|
||||||
|
name: '小说设定生成',
|
||||||
|
description: '根据用户的小说创意,自动生成完整的小说设定',
|
||||||
|
category: '创作辅助',
|
||||||
|
prompt: '你是一个专业的小说设定助手...',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'skill_2',
|
||||||
|
name: '章节内容创作',
|
||||||
|
description: '根据章节标题和细纲,自动创作具体的章节内容',
|
||||||
|
category: '内容创作',
|
||||||
|
prompt: '你是一个专业的小说作家...',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 可以存储到 localStorage 或 IndexedDB
|
||||||
|
localStorage.setItem('defaultSkills', JSON.stringify(defaultSkills));
|
||||||
|
console.log('技能库初始化完成');
|
||||||
|
};
|
||||||
|
|
||||||
|
const configureSystem = async () => {
|
||||||
|
// 配置其他系统参数
|
||||||
|
const systemConfig = {
|
||||||
|
version: '0.0.1',
|
||||||
|
theme: 'light',
|
||||||
|
language: 'zh-CN',
|
||||||
|
autoSave: true,
|
||||||
|
maxHistory: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('systemConfig', JSON.stringify(systemConfig));
|
||||||
|
console.log('系统参数配置完成');
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifySystem = async () => {
|
||||||
|
// 验证各个组件是否正常工作
|
||||||
|
const checks = [
|
||||||
|
{
|
||||||
|
name: 'IndexedDB',
|
||||||
|
check: async () => {
|
||||||
|
const { indexedDBStorage } = await import('../utils/indexedDB');
|
||||||
|
await indexedDBStorage.init();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '本地存储',
|
||||||
|
check: () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('test', 'test');
|
||||||
|
localStorage.removeItem('test');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, check } of checks) {
|
||||||
|
const result = await check();
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`${name}验证失败`);
|
||||||
|
}
|
||||||
|
console.log(`${name}验证通过`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const getSteps = () => {
|
||||||
|
if (initMode === 'setup') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '初始化数据库',
|
||||||
|
description: '创建IndexedDB数据库和存储结构',
|
||||||
|
icon: <DatabaseOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建默认配置',
|
||||||
|
description: '设置系统默认的Ollama配置',
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '初始化技能库',
|
||||||
|
description: '加载默认的AI创作技能',
|
||||||
|
icon: <FundOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配置系统参数',
|
||||||
|
description: '设置主题、语言等系统参数',
|
||||||
|
icon: <CloudUploadOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验证系统状态',
|
||||||
|
description: '检查所有组件是否正常工作',
|
||||||
|
icon: <SafetyOutlined />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '清除IndexedDB',
|
||||||
|
description: '删除所有存储的小说、章节等数据',
|
||||||
|
icon: <DatabaseOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '清除本地配置',
|
||||||
|
description: '清除所有本地存储的配置信息',
|
||||||
|
icon: <DeleteOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '重新初始化',
|
||||||
|
description: '重新创建数据库和存储结构',
|
||||||
|
icon: <ReloadOutlined />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '恢复默认配置',
|
||||||
|
description: '设置系统的默认配置参数',
|
||||||
|
icon: <SettingOutlined />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<RocketOutlined style={{ color: '#52c41a' }} />
|
||||||
|
) : (
|
||||||
|
<ClearOutlined style={{ color: '#1890ff' }} />
|
||||||
|
)}
|
||||||
|
{initMode === 'setup' ? '系统初始化配置' : '系统重置'}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||||
|
{initMode === 'setup'
|
||||||
|
? '首次使用本系统,一键配置所有必要的组件和参数'
|
||||||
|
: '一键重置系统所有数据和配置到初始状态'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<Alert
|
||||||
|
message="欢迎使用AI小说创作系统"
|
||||||
|
description="检测到您是首次使用本系统,我们将为您自动配置必要的组件和设置,整个过程大约需要10-20秒。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
icon={<RocketOutlined />}
|
||||||
|
style={{ marginBottom: '24px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
message="危险操作警告"
|
||||||
|
description="此操作将清除所有数据,包括小说、章节、配置等,且不可恢复。请谨慎操作!"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
icon={<ExclamationCircleOutlined />}
|
||||||
|
style={{ marginBottom: '24px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginBottom: '24px' }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="小说数量"
|
||||||
|
value={dbStats.novels}
|
||||||
|
prefix={<DatabaseOutlined />}
|
||||||
|
valueStyle={{ color: dbStats.novels > 0 ? '#1890ff' : '#999' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="章节数量"
|
||||||
|
value={dbStats.chapters}
|
||||||
|
prefix={<DatabaseOutlined />}
|
||||||
|
valueStyle={{ color: dbStats.chapters > 0 ? '#52c41a' : '#999' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="配置文件"
|
||||||
|
value={dbStats.settings}
|
||||||
|
prefix={<SettingOutlined />}
|
||||||
|
valueStyle={{ color: dbStats.settings > 0 ? '#faad14' : '#999' }}
|
||||||
|
suffix="个"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="初始化进度"
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type={initMode === 'setup' ? 'primary' : 'primary'}
|
||||||
|
danger={initMode !== 'setup'}
|
||||||
|
icon={initMode === 'setup' ? <RocketOutlined /> : <ClearOutlined />}
|
||||||
|
onClick={handleInit}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? (initMode === 'setup' ? '配置中...' : '初始化中...')
|
||||||
|
: (initMode === 'setup' ? '开始配置' : '开始初始化')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<Steps
|
||||||
|
current={currentStep}
|
||||||
|
status={initStatus.status}
|
||||||
|
items={getSteps().map((step, index) => ({
|
||||||
|
...step,
|
||||||
|
status: index < currentStep ? 'finish' :
|
||||||
|
index === currentStep ? initStatus.status : 'wait'
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: initStatus.status === 'error' ? '#fff2f0' :
|
||||||
|
initStatus.status === 'finish' ? '#f6ffed' : '#e6f7ff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${initStatus.status === 'error' ? '#ffccc7' :
|
||||||
|
initStatus.status === 'finish' ? '#b7eb8f' : '#91d5ff'}`
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
{initStatus.status === 'process' && <ReloadOutlined spin style={{ color: '#1890ff' }} />}
|
||||||
|
{initStatus.status === 'finish' && <CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||||
|
{initStatus.status === 'error' && <ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />}
|
||||||
|
<Text strong>{initStatus.message}</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentStep < getSteps().length && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<Progress
|
||||||
|
percent={Math.round(((currentStep + 1) / getSteps().length) * 100)}
|
||||||
|
status={initStatus.status === 'error' ? 'exception' : 'active'}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#108ee9',
|
||||||
|
'100%': '#87d068',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentStep === 0 && (
|
||||||
|
<div>
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<div>
|
||||||
|
<Descriptions title="首次配置将执行以下操作" bordered column={1}>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><DatabaseOutlined />初始化数据库</Space>}
|
||||||
|
>
|
||||||
|
创建IndexedDB数据库和存储结构,用于存储小说、章节等数据
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><SettingOutlined />创建默认配置</Space>}
|
||||||
|
>
|
||||||
|
设置Ollama服务连接参数和AI模型配置
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><FundOutlined />初始化技能库</Space>}
|
||||||
|
>
|
||||||
|
加载默认的AI创作技能模板,如小说设定生成、章节创作等
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><CloudUploadOutlined />配置系统参数</Space>}
|
||||||
|
>
|
||||||
|
设置界面主题、语言、自动保存等系统参数
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><SafetyOutlined />验证系统状态</Space>}
|
||||||
|
>
|
||||||
|
检查数据库连接、本地存储等组件是否正常工作
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message="配置完成后您将获得"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
<li>完整的数据存储系统</li>
|
||||||
|
<li>预配置的AI模型连接参数</li>
|
||||||
|
<li>丰富的AI创作技能库</li>
|
||||||
|
<li>优化的系统使用体验</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Descriptions title="初始化将执行以下操作" bordered column={1}>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><DatabaseOutlined />清除IndexedDB</Space>}
|
||||||
|
>
|
||||||
|
删除所有存储的小说、章节、草稿等数据
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><DeleteOutlined />清除本地配置</Space>}
|
||||||
|
>
|
||||||
|
清除所有本地存储的Ollama设置和用户配置
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><ReloadOutlined />重新初始化</Space>}
|
||||||
|
>
|
||||||
|
重新创建数据库结构和存储对象
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
label={<Space><SettingOutlined />恢复默认配置</Space>}
|
||||||
|
>
|
||||||
|
设置系统默认的参数配置
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
message="建议在以下情况下执行初始化"
|
||||||
|
description={
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
<li>系统出现异常数据错误</li>
|
||||||
|
<li>数据库损坏或无法正常访问</li>
|
||||||
|
<li>需要完全清除所有数据重新开始</li>
|
||||||
|
<li>升级到不兼容的新版本</li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === getSteps().length && initStatus.status === 'finish' && (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
message={initMode === 'setup' ? '系统配置完成!' : '系统初始化成功'}
|
||||||
|
description={initMode === 'setup'
|
||||||
|
? '所有必要的组件和参数已配置完成,系统现在可以正常使用了。'
|
||||||
|
: '所有数据和配置已重置到初始状态,系统现在可以正常使用了。'}
|
||||||
|
type="success"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<>
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>数据库已初始化</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>IndexedDB数据库和存储结构已创建完成</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>配置已设置</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>Ollama服务和AI模型配置已设置完成</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>技能库已加载</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>默认AI创作技能模板已加载完成</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>系统已就绪</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>所有组件运行正常,可以开始创作之旅</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>IndexedDB已重置</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>数据库已重新创建,可以开始新的创作</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>配置已恢复</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>系统配置已恢复到默认设置</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card size="small" style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}>
|
||||||
|
<Space>
|
||||||
|
<SafetyOutlined style={{ color: '#52c41a', fontSize: '20px' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>系统就绪</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>所有组件运行正常,可以开始使用</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<RocketOutlined style={{ color: '#52c41a' }} />
|
||||||
|
) : (
|
||||||
|
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
|
||||||
|
)}
|
||||||
|
{initMode === 'setup' ? '确认开始系统配置' : '确认初始化系统'}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={showConfirmModal}
|
||||||
|
onOk={confirmInit}
|
||||||
|
onCancel={() => setShowConfirmModal(false)}
|
||||||
|
okText={initMode === 'setup' ? '开始配置' : '确认初始化'}
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: initMode !== 'setup' }}
|
||||||
|
>
|
||||||
|
{initMode === 'setup' ? (
|
||||||
|
<Alert
|
||||||
|
message="准备开始系统配置"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>系统将为您自动配置以下组件:</p>
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
<li>创建IndexedDB数据库结构</li>
|
||||||
|
<li>设置Ollama服务连接参数</li>
|
||||||
|
<li>加载默认AI创作技能库</li>
|
||||||
|
<li>配置系统界面和功能参数</li>
|
||||||
|
<li>验证所有组件正常工作</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ color: '#52c41a', fontWeight: 'bold' }}>
|
||||||
|
整个过程大约需要10-20秒,请耐心等待。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
message="此操作不可逆"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>您即将执行系统初始化操作,这将:</p>
|
||||||
|
<ul style={{ margin: '8px 0', paddingLeft: '20px' }}>
|
||||||
|
<li><strong>永久删除</strong>所有小说数据({dbStats.novels}个小说)</li>
|
||||||
|
<li><strong>永久删除</strong>所有章节内容({dbStats.chapters}个章节)</li>
|
||||||
|
<li><strong>清除所有</strong>配置和设置</li>
|
||||||
|
<li><strong>重置系统</strong>到初始状态</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ color: '#ff4d4f', fontWeight: 'bold' }}>
|
||||||
|
这些数据将无法恢复!请确保已做好数据备份。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initMode !== 'setup' && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<Text strong>请输入 "CONFIRM" 来确认此操作:</Text>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="输入 CONFIRM 确认"
|
||||||
|
onChange={(e) => {
|
||||||
|
// 可以添加额外的确认逻辑
|
||||||
|
}}
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InitPage;
|
||||||
468
src/pages/ModelSettings.tsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Form, Input, InputNumber, Button, Space, Divider, Select, Spin, Tag, App } from 'antd';
|
||||||
|
import { ApiOutlined, SaveOutlined, SyncOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { OllamaService } from '../utils/ollama';
|
||||||
|
import { useOllama } from '../contexts/OllamaContext';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelSettings: React.FC = () => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [detectingModels, setDetectingModels] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||||
|
const [currentModel, setCurrentModel] = useState<string>('');
|
||||||
|
const { status, refreshModels } = useOllama();
|
||||||
|
const { message: messageApi } = App.useApp();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
// 从 localStorage 加载配置
|
||||||
|
const configData = localStorage.getItem('ai_system_config');
|
||||||
|
const config = configData ? JSON.parse(configData) : {
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setFieldsValue(config);
|
||||||
|
setCurrentModel(config.model || '');
|
||||||
|
|
||||||
|
// 如果已连接且有模型,自动显示当前状态
|
||||||
|
if (status.isConnected && status.availableModels.length > 0) {
|
||||||
|
setAvailableModels(status.availableModels);
|
||||||
|
setConnectionStatus('success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadConfig();
|
||||||
|
}, [form, status]);
|
||||||
|
|
||||||
|
const handleDetectModels = async () => {
|
||||||
|
setDetectingModels(true);
|
||||||
|
setConnectionStatus('unknown');
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields(['ollamaUrl']);
|
||||||
|
const ollamaService = new OllamaService(values);
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const isConnected = await ollamaService.testConnection();
|
||||||
|
if (!isConnected) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: '无法连接到 Ollama 服务,请检查服务地址和状态',
|
||||||
|
});
|
||||||
|
setAvailableModels([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型列表
|
||||||
|
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||||
|
setAvailableModels(models);
|
||||||
|
setConnectionStatus('success');
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
messageApi.open({
|
||||||
|
type: 'warning',
|
||||||
|
content: '未检测到已安装的模型,请先使用 ollama pull 命令安装模型',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messageApi.open({
|
||||||
|
type: 'success',
|
||||||
|
content: `成功检测到 ${models.length} 个已安装模型`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果当前模型不在列表中,清空选择
|
||||||
|
if (currentModel && !models.find(m => m.name === currentModel)) {
|
||||||
|
form.setFieldValue('model', undefined);
|
||||||
|
setCurrentModel('');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'warning',
|
||||||
|
content: '当前选择的模型未在本地安装,请重新选择',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新全局状态
|
||||||
|
await refreshModels();
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: '模型检测失败,请检查 Ollama 服务状态',
|
||||||
|
});
|
||||||
|
setAvailableModels([]);
|
||||||
|
} finally {
|
||||||
|
setDetectingModels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setConnectionStatus('unknown');
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const ollamaService = new OllamaService(values);
|
||||||
|
const isConnected = await ollamaService.testConnection();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
setConnectionStatus('success');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'success',
|
||||||
|
content: 'Ollama 服务连接成功!',
|
||||||
|
});
|
||||||
|
await refreshModels();
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: 'Ollama 服务连接失败,请检查服务地址',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: '连接测试失败,请检查配置',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = (value: string) => {
|
||||||
|
setCurrentModel(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
// 验证选择的模型是否可用
|
||||||
|
if (currentModel && availableModels.length > 0) {
|
||||||
|
const modelExists = availableModels.find(m => m.name === currentModel);
|
||||||
|
if (!modelExists) {
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: '请选择本地已安装的模型,或点击"检测模型"刷新列表',
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保包含当前选择的模型
|
||||||
|
const configToSave = {
|
||||||
|
...values,
|
||||||
|
model: currentModel || values.model
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
localStorage.setItem('ai_system_config', JSON.stringify(configToSave));
|
||||||
|
|
||||||
|
// 显示保存成功提示
|
||||||
|
messageApi.open({
|
||||||
|
type: 'success',
|
||||||
|
content: '配置保存成功!',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新当前模型显示
|
||||||
|
setCurrentModel(configToSave.model);
|
||||||
|
|
||||||
|
// 刷新全局状态
|
||||||
|
await refreshModels();
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.open({
|
||||||
|
type: 'error',
|
||||||
|
content: '配置保存失败,请检查输入',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatModelSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return '未知';
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
return `${gb.toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>模型设置</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>配置本地 Ollama 服务和 AI 模型参数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
marginBottom: '16px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: connectionStatus === 'success' ? '#52c41a' : connectionStatus === 'error' ? '#ff4d4f' : '#faad14'
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, color: '#262626' }}>
|
||||||
|
{connectionStatus === 'success' ? '服务正常' : connectionStatus === 'error' ? '服务异常' : '未检测'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
{availableModels.length > 0
|
||||||
|
? `已安装 ${availableModels.length} 个模型`
|
||||||
|
: connectionStatus === 'success'
|
||||||
|
? '未安装模型'
|
||||||
|
: '请先连接服务'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card bordered={false} style={{ boxShadow: 'none' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
服务配置
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>Ollama 服务地址</span>}
|
||||||
|
name="ollamaUrl"
|
||||||
|
rules={[{ required: true, message: '请输入Ollama服务地址' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: '16px' }}>
|
||||||
|
<Button
|
||||||
|
icon={<ApiOutlined />}
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
loading={testing}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={handleDetectModels}
|
||||||
|
loading={detectingModels}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
检测模型
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
模型选择
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<Space>
|
||||||
|
<span style={{ color: '#595959' }}>AI 模型</span>
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<Tag color="success">{availableModels.length} 个可用</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
name="model"
|
||||||
|
rules={[{
|
||||||
|
required: true,
|
||||||
|
message: '请选择AI模型'
|
||||||
|
}]}
|
||||||
|
tooltip="只能选择本地已安装的模型"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={availableModels.length === 0 ? "请先点击检测模型" : "选择AI模型"}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
onChange={handleModelChange}
|
||||||
|
notFoundContent={detectingModels ? <Spin size="small" /> : "未检测到可用模型"}
|
||||||
|
disabled={availableModels.length === 0}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<Option key={model.name} value={model.name}>
|
||||||
|
<Space>
|
||||||
|
<span>{model.name}</span>
|
||||||
|
{model.size && (
|
||||||
|
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>
|
||||||
|
({formatModelSize(model.size)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{currentModel && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span style={{ color: '#52c41a', fontSize: '14px' }}>
|
||||||
|
当前使用模型: {currentModel}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
生成参数
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>温度</span>}
|
||||||
|
name="temperature"
|
||||||
|
rules={[{ required: true, message: '请输入温度值' }]}
|
||||||
|
tooltip="控制生成文本的随机性"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
precision={1}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>Top P</span>}
|
||||||
|
name="topP"
|
||||||
|
rules={[{ required: true, message: '请输入Top P值' }]}
|
||||||
|
tooltip="控制生成文本的多样性"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
precision={1}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>最大 Tokens</span>}
|
||||||
|
name="maxTokens"
|
||||||
|
rules={[{ required: true, message: '请输入最大生成长度' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={100}
|
||||||
|
max={8000}
|
||||||
|
step={100}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: '6px', minWidth: '120px' }}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '32px 0' }} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
使用说明
|
||||||
|
</div>
|
||||||
|
<ul style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
color: '#595959',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.8'
|
||||||
|
}}>
|
||||||
|
<li>确保 Ollama 服务正在运行(默认端口 11434)</li>
|
||||||
|
<li>点击"测试连接"验证 Ollama 服务是否正常</li>
|
||||||
|
<li>点击"检测模型"获取本地已安装的模型列表</li>
|
||||||
|
<li>模型选择限制为只能使用本地已安装的模型</li>
|
||||||
|
<li>如需新模型,请使用命令:ollama pull 模型名</li>
|
||||||
|
<li>推荐:qwen3:8b起步</li>
|
||||||
|
<li>温度参数控制生成文本的随机性,建议值为 0.7</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelSettings;
|
||||||
1421
src/pages/NovelDetail.tsx
Normal file
1339
src/pages/NovelGenerate.tsx
Normal file
414
src/pages/NovelList.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Modal, Form, Input, message, Popconfirm, Row, Col, Tag, Select } from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { storage } from '../utils/indexedDB';
|
||||||
|
import { Novel } from '../types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
// 小说题材选项
|
||||||
|
const NOVEL_GENRES = [
|
||||||
|
'穿越',
|
||||||
|
'都市',
|
||||||
|
'修仙',
|
||||||
|
'武侠',
|
||||||
|
'玄幻',
|
||||||
|
'科幻',
|
||||||
|
'言情',
|
||||||
|
'历史',
|
||||||
|
'游戏',
|
||||||
|
'灵异',
|
||||||
|
'军事',
|
||||||
|
'悬疑',
|
||||||
|
'其他'
|
||||||
|
];
|
||||||
|
|
||||||
|
const NovelList: React.FC = () => {
|
||||||
|
const [novels, setNovels] = useState<Novel[]>([]);
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [viewModalVisible, setViewModalVisible] = useState(false);
|
||||||
|
const [editingNovel, setEditingNovel] = useState<Novel | null>(null);
|
||||||
|
const [viewingNovel, setViewingNovel] = useState<Novel | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNovels();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNovels = async () => {
|
||||||
|
try {
|
||||||
|
const loadedNovels = await storage.getNovels();
|
||||||
|
setNovels(loadedNovels);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载小说列表失败:', error);
|
||||||
|
message.error('加载小说列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddNovel = () => {
|
||||||
|
setEditingNovel(null);
|
||||||
|
form.resetFields();
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewNovel = (novel: Novel) => {
|
||||||
|
setViewingNovel(novel);
|
||||||
|
setViewModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditNovel = (novel: Novel) => {
|
||||||
|
setEditingNovel(novel);
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: novel.title,
|
||||||
|
genre: novel.genre === '未分类' ? undefined : novel.genre,
|
||||||
|
outline: novel.outline
|
||||||
|
});
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNovel = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await storage.deleteNovel(id);
|
||||||
|
await loadNovels();
|
||||||
|
message.success('小说删除成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除小说失败:', error);
|
||||||
|
message.error('删除失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalOk = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
if (editingNovel) {
|
||||||
|
await storage.updateNovel(editingNovel.id, {
|
||||||
|
...values,
|
||||||
|
genre: values.genre || '未分类'
|
||||||
|
});
|
||||||
|
message.success('小说更新成功');
|
||||||
|
} else {
|
||||||
|
const newNovel: Novel = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: values.title,
|
||||||
|
genre: values.genre || '未分类',
|
||||||
|
outline: values.outline || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
await storage.addNovel(newNovel);
|
||||||
|
message.success('小说创建成功,请前往AI生成页面完善设定');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadNovels();
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存小说失败:', error);
|
||||||
|
message.error('保存失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalCancel = () => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setEditingNovel(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardClick = (id: string) => {
|
||||||
|
navigate(`/novels/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>我的小说</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>管理和创作您的 AI 小说作品</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#595959' }}>
|
||||||
|
共 {novels.length} 部小说
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddNovel}
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
新建小说
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{novels.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '80px 20px',
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px dashed #d9d9d9'
|
||||||
|
}}>
|
||||||
|
<BookOutlined style={{ fontSize: '48px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||||
|
<div style={{ fontSize: '16px', color: '#595959', marginBottom: '8px' }}>
|
||||||
|
还没有小说,开始创作您的第一部作品吧!
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>
|
||||||
|
点击"新建小说"按钮开始您的创作之旅
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{novels.map((novel) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} key={novel.id}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
onClick={() => handleCardClick(novel.id)}
|
||||||
|
actions={[
|
||||||
|
<EyeOutlined
|
||||||
|
key="view"
|
||||||
|
style={{ color: '#52c41a' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewNovel(novel);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<EditOutlined
|
||||||
|
key="edit"
|
||||||
|
style={{ color: '#1890ff' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditNovel(novel);
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这部小说吗?所有章节内容也将被删除。"
|
||||||
|
onConfirm={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
handleDeleteNovel(novel.id);
|
||||||
|
}}
|
||||||
|
onCancel={(e) => e?.stopPropagation()}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<DeleteOutlined
|
||||||
|
key="delete"
|
||||||
|
style={{ color: '#ff4d4f' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
avatar={
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#e6f7ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<BookOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}>
|
||||||
|
{novel.title}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<Tag color="blue">{novel.genre}</Tag>
|
||||||
|
{novel.generatedSettings && (
|
||||||
|
<Tag color="green">已完善设定</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}>
|
||||||
|
{novel.outline || '暂无大纲'}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', fontSize: '11px', color: '#bfbfbf' }}>
|
||||||
|
创建于 {new Date(novel.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingNovel ? '编辑小说' : '新建小说'}
|
||||||
|
open={isModalVisible}
|
||||||
|
onOk={handleModalOk}
|
||||||
|
onCancel={handleModalCancel}
|
||||||
|
width={600}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="书名"
|
||||||
|
name="title"
|
||||||
|
rules={[{ required: true, message: '请输入书名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入书名" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="题材类型"
|
||||||
|
name="genre"
|
||||||
|
rules={[{ required: true, message: '请选择题材类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择题材类型"
|
||||||
|
size="large"
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
>
|
||||||
|
{NOVEL_GENRES.map(genre => (
|
||||||
|
<Option key={genre} value={genre}>
|
||||||
|
{genre}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="初步想法"
|
||||||
|
name="outline"
|
||||||
|
extra="可以简单描述您的初步想法,也可以留空,后续让AI帮您生成"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="简单描述您的创作想法..."
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="小说详情"
|
||||||
|
open={viewModalVisible}
|
||||||
|
onCancel={() => setViewModalVisible(false)}
|
||||||
|
width={700}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" onClick={() => setViewModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{viewingNovel && (
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||||
|
书名
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
{viewingNovel.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||||
|
题材
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '6px'
|
||||||
|
}}>
|
||||||
|
<Tag color="blue">{viewingNovel.genre}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewingNovel.generatedSettings && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||||
|
AI生成设定
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f0f9ff',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<div><strong>目标字数:</strong>{viewingNovel.generatedSettings.targetWordCount}字</div>
|
||||||
|
<div><strong>章节数量:</strong>{viewingNovel.generatedSettings.chapterCount}章</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
|
||||||
|
大纲
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: '6px',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{viewingNovel.outline || '暂无大纲'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', paddingTop: '16px', borderTop: '1px solid #f0f0f0' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
创建时间:{new Date(viewingNovel.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c', marginTop: '4px' }}>
|
||||||
|
更新时间:{new Date(viewingNovel.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NovelList;
|
||||||
478
src/pages/SkillsPage.tsx
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Popconfirm,
|
||||||
|
Divider
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CodeOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
EditOutlined as EditIcon
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
prompt: string;
|
||||||
|
examples: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SkillsPage: React.FC = () => {
|
||||||
|
const [skills, setSkills] = useState<Skill[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSkills();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSkills = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 这里将来可以连接到实际的数据库
|
||||||
|
// 现在先使用模拟数据
|
||||||
|
const mockSkills: Skill[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: '小说设定生成',
|
||||||
|
description: '根据用户的小说创意,自动生成完整的小说设定,包括角色、世界观、故事大纲等',
|
||||||
|
category: '创作辅助',
|
||||||
|
prompt: '你是一个专业的小说设定助手。根据用户提供的小说创意,生成详细的小说设定,包括:1. 故事大纲(150字内)2. 主要角色设定(至少3个角色)3. 世界观背景 4. 核心冲突 5. 情节发展建议',
|
||||||
|
examples: '用户输入:写一个现代都市修仙小说\n\n生成结果:\n故事大纲:普通程序员意外获得修仙传承,在现代都市中一边工作一边修炼,逐渐发现都市中隐藏的修仙世界...',
|
||||||
|
createdAt: '2024-01-15',
|
||||||
|
updatedAt: '2024-01-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: '章节内容创作',
|
||||||
|
description: '根据章节标题和细纲,自动创作具体的章节内容,保持风格连贯和情节合理',
|
||||||
|
category: '内容创作',
|
||||||
|
prompt: '你是一个专业的小说作家。根据提供的章节标题、细纲和前文内容,创作符合要求的章节内容。要求:1. 严格遵守细纲要求 2. 保持人物性格一致 3. 语言生动流畅 4. 字数控制在900-1200字',
|
||||||
|
examples: '章节标题:第一章 意外穿越\n细纲:主角意外穿越到修仙世界,获得神秘传承\n前文:无\n\n生成结果:林明醒来时,发现自己躺在一片陌生的森林中...',
|
||||||
|
createdAt: '2024-01-16',
|
||||||
|
updatedAt: '2024-01-16'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: '角色对话优化',
|
||||||
|
description: '优化小说中的人物对话,使其更符合角色性格和场景氛围',
|
||||||
|
category: '内容优化',
|
||||||
|
prompt: '你是一个对话优化专家。根据提供的对话内容和角色设定,优化对话表达,使其:1. 更符合角色性格 2. 更贴合场景氛围 3. 语言更自然流畅 4. 保持原有意思不变',
|
||||||
|
examples: '原对话:"你是什么人?"林明问。\n优化后:"你是谁?"林明的声音里带着警惕,眼神紧盯着对方...',
|
||||||
|
createdAt: '2024-01-17',
|
||||||
|
updatedAt: '2024-01-17'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: '情节建议生成',
|
||||||
|
description: '为小说创作提供情节发展建议,帮助解决创作瓶颈',
|
||||||
|
category: '创作辅助',
|
||||||
|
prompt: '你是一个创意写作顾问。根据用户提供的当前情节和创作瓶颈,提供3-5个情节发展建议,每个建议都要:1. 符合故事逻辑 2. 具有戏剧冲突 3. 推动情节发展 4. 保持人物一致性',
|
||||||
|
examples: '当前情节:主角刚刚获得修仙传承,但不知道如何修炼\n\n建议1:安排一位神秘导师,指点主角入门...\n建议2:主角在修炼过程中遇到困难,需要寻找资源...',
|
||||||
|
createdAt: '2024-01-18',
|
||||||
|
updatedAt: '2024-01-18'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: '文笔风格调整',
|
||||||
|
description: '调整小说的文笔风格,如简洁、华丽、幽默等不同风格',
|
||||||
|
category: '内容优化',
|
||||||
|
prompt: '你是一个文笔风格调整专家。根据用户指定的风格要求,调整文本的表达方式。可选风格:简洁明快、华丽优美、幽默风趣、严肃深沉等。要求保持原意不变,只改变表达方式。',
|
||||||
|
examples: '原文:他走进了房间,看到了一个人坐在那里。\n简洁风格:他进屋,见一人独坐。\n华丽风格:他缓步入室,目光所及,见一人静坐其间...',
|
||||||
|
createdAt: '2024-01-19',
|
||||||
|
updatedAt: '2024-01-19'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: '章节大纲生成',
|
||||||
|
description: '为指定章节生成详细的章节大纲,包括主要情节、转折点、人物发展等',
|
||||||
|
category: '创作辅助',
|
||||||
|
prompt: '你是一个章节大纲专家。根据小说总体设定和章节要求,生成详细的章节大纲。大纲应包含:1. 章节主题 2. 主要情节发展 3. 重要转折点 4. 人物心理变化 5. 与前后章节的衔接',
|
||||||
|
examples: '小说:都市修仙\n章节:第5章\n要求:主角首次展示修仙能力\n\n大纲:章节主题:初露锋芒\n主要情节:主角在公司遇到危机,情急之下使用修仙能力...',
|
||||||
|
createdAt: '2024-01-20',
|
||||||
|
updatedAt: '2024-01-20'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setSkills(mockSkills);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载技能列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingSkill(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (skill: Skill) => {
|
||||||
|
setEditingSkill(skill);
|
||||||
|
form.setFieldsValue(skill);
|
||||||
|
setModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
// 这里将来连接到实际的数据库删除操作
|
||||||
|
setSkills(skills.filter(skill => skill.id !== id));
|
||||||
|
message.success('技能删除成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除技能失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
if (editingSkill) {
|
||||||
|
// 编辑模式
|
||||||
|
const updatedSkill: Skill = {
|
||||||
|
...editingSkill,
|
||||||
|
...values,
|
||||||
|
updatedAt: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
setSkills(skills.map(skill =>
|
||||||
|
skill.id === editingSkill.id ? updatedSkill : skill
|
||||||
|
));
|
||||||
|
message.success('技能更新成功');
|
||||||
|
} else {
|
||||||
|
// 新增模式
|
||||||
|
const newSkill: Skill = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
...values,
|
||||||
|
createdAt: new Date().toISOString().split('T')[0],
|
||||||
|
updatedAt: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
setSkills([...skills, newSkill]);
|
||||||
|
message.success('技能添加成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('表单验证失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case '创作辅助':
|
||||||
|
return <BulbOutlined style={{ color: '#faad14', fontSize: '20px' }} />;
|
||||||
|
case '内容创作':
|
||||||
|
return <EditIcon style={{ color: '#52c41a', fontSize: '20px' }} />;
|
||||||
|
case '内容优化':
|
||||||
|
return <CodeOutlined style={{ color: '#1890ff', fontSize: '20px' }} />;
|
||||||
|
default:
|
||||||
|
return <ThunderboltOutlined style={{ color: '#8c8c8c', fontSize: '20px' }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case '创作辅助':
|
||||||
|
return 'orange';
|
||||||
|
case '内容创作':
|
||||||
|
return 'green';
|
||||||
|
case '内容优化':
|
||||||
|
return 'blue';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<ThunderboltOutlined style={{ color: '#1890ff' }} />
|
||||||
|
技能管理
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||||
|
管理和配置 AI 智能体的各种创作技能
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAdd}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
新建技能
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#f0f9ff', border: '1px solid #91d5ff' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#1890ff' }}>
|
||||||
|
{skills.length}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
总技能数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#f6ffed', border: '1px solid #b7eb8f' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#52c41a' }}>
|
||||||
|
{skills.filter(s => s.category === '创作辅助').length}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
创作辅助
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ background: '#fff9e6', border: '1px solid #ffe58f' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: '#faad14' }}>
|
||||||
|
{skills.filter(s => s.category === '内容创作' || s.category === '内容优化').length}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
内容处理
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '50px' }}>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))',
|
||||||
|
gap: '16px'
|
||||||
|
}}>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<Card
|
||||||
|
key={skill.id}
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
borderRadius: '12px',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="edit"
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(skill)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个技能吗?"
|
||||||
|
onConfirm={() => handleDelete(skill.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: '#f0f9ff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: '12px'
|
||||||
|
}}>
|
||||||
|
{getCategoryIcon(skill.category)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Title level={4} style={{ margin: 0, marginBottom: '8px' }}>
|
||||||
|
{skill.name}
|
||||||
|
</Title>
|
||||||
|
<Tag color={getCategoryColor(skill.category)}>
|
||||||
|
{skill.category}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
style={{
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '16px',
|
||||||
|
minHeight: '44px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skill.description}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '12px 0' }} />
|
||||||
|
|
||||||
|
<div style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
<div style={{ marginBottom: '4px' }}>
|
||||||
|
<BookOutlined style={{ marginRight: '4px' }} />
|
||||||
|
提示词模板:{skill.prompt.length} 字符
|
||||||
|
</div>
|
||||||
|
{skill.examples && (
|
||||||
|
<div>
|
||||||
|
<BulbOutlined style={{ marginRight: '4px' }} />
|
||||||
|
包含示例
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
更新时间:{skill.updatedAt}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{skills.length === 0 && !loading && (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '100px 0',
|
||||||
|
color: '#999'
|
||||||
|
}}>
|
||||||
|
<RobotOutlined style={{ fontSize: '64px', color: '#d9d9d9', marginBottom: '16px' }} />
|
||||||
|
<div>还没有技能,点击上方按钮创建第一个技能吧!</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<ThunderboltOutlined style={{ color: '#1890ff' }} />
|
||||||
|
{editingSkill ? '编辑技能' : '新建技能'}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
width={800}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
style={{ marginTop: '24px' }}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="技能名称"
|
||||||
|
rules={[{ required: true, message: '请输入技能名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:小说设定生成" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="category"
|
||||||
|
label="技能分类"
|
||||||
|
rules={[{ required: true, message: '请选择技能分类' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="选择分类">
|
||||||
|
<Select.Option value="创作辅助">创作辅助</Select.Option>
|
||||||
|
<Select.Option value="内容创作">内容创作</Select.Option>
|
||||||
|
<Select.Option value="内容优化">内容优化</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="技能描述"
|
||||||
|
rules={[{ required: true, message: '请输入技能描述' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="简单描述这个技能的功能和用途"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="prompt"
|
||||||
|
label="提示词模板"
|
||||||
|
rules={[{ required: true, message: '请输入提示词模板' }]}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="输入 AI 使用的提示词模板,可以使用变量占位符"
|
||||||
|
rows={6}
|
||||||
|
style={{ fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="examples"
|
||||||
|
label="使用示例"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="输入使用示例,帮助用户理解如何使用这个技能"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SkillsPage;
|
||||||
399
src/pages/SystemConfig.tsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Form, Input, InputNumber, Button, Space, message, Divider, Select, Spin, Tag } from 'antd';
|
||||||
|
import { ApiOutlined, SaveOutlined, SyncOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { storage } from '../utils/indexedDB';
|
||||||
|
import { OllamaService } from '../utils/ollama';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemConfig: React.FC = () => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [detectingModels, setDetectingModels] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'success' | 'error'>('unknown');
|
||||||
|
const [currentModel, setCurrentModel] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
const config = await storage.getSystemConfig();
|
||||||
|
form.setFieldsValue(config);
|
||||||
|
setCurrentModel(config.model || '');
|
||||||
|
};
|
||||||
|
loadConfig();
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const handleDetectModels = async () => {
|
||||||
|
setDetectingModels(true);
|
||||||
|
setConnectionStatus('unknown');
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields(['ollamaUrl']);
|
||||||
|
const ollamaService = new OllamaService(values);
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const isConnected = await ollamaService.testConnection();
|
||||||
|
if (!isConnected) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
message.error('无法连接到 Ollama 服务,请检查服务地址和状态');
|
||||||
|
setAvailableModels([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取模型列表
|
||||||
|
const models = await ollamaService.getAvailableModelsWithInfo();
|
||||||
|
setAvailableModels(models);
|
||||||
|
setConnectionStatus('success');
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
message.warning('未检测到已安装的模型,请先使用 ollama pull 命令安装模型');
|
||||||
|
} else {
|
||||||
|
message.success(`成功检测到 ${models.length} 个已安装模型`);
|
||||||
|
|
||||||
|
// 如果当前模型不在列表中,清空选择
|
||||||
|
if (currentModel && !models.find(m => m.name === currentModel)) {
|
||||||
|
form.setFieldValue('model', undefined);
|
||||||
|
setCurrentModel('');
|
||||||
|
message.warning('当前选择的模型未在本地安装,请重新选择');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
message.error('模型检测失败,请检查 Ollama 服务状态');
|
||||||
|
setAvailableModels([]);
|
||||||
|
} finally {
|
||||||
|
setDetectingModels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setConnectionStatus('unknown');
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const ollamaService = new OllamaService(values);
|
||||||
|
const isConnected = await ollamaService.testConnection();
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
setConnectionStatus('success');
|
||||||
|
message.success('Ollama 服务连接成功!');
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
message.error('Ollama 服务连接失败,请检查服务地址');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionStatus('error');
|
||||||
|
message.error('连接测试失败,请检查配置');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = (value: string) => {
|
||||||
|
setCurrentModel(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
// 验证选择的模型是否可用
|
||||||
|
if (currentModel && availableModels.length > 0) {
|
||||||
|
const modelExists = availableModels.find(m => m.name === currentModel);
|
||||||
|
if (!modelExists) {
|
||||||
|
message.error('请选择本地已安装的模型,或点击"检测模型"刷新列表');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveSystemConfig(values);
|
||||||
|
message.success('配置保存成功!');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('配置保存失败,请检查输入');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatModelSize = (bytes?: number) => {
|
||||||
|
if (!bytes) return '未知';
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024);
|
||||||
|
return `${gb.toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}>系统配置</h2>
|
||||||
|
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}>配置 Ollama 服务和 AI 模型参数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
marginBottom: '16px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
background: '#fafafa',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: connectionStatus === 'success' ? '#52c41a' : connectionStatus === 'error' ? '#ff4d4f' : '#faad14'
|
||||||
|
}} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, color: '#262626' }}>
|
||||||
|
{connectionStatus === 'success' ? '服务正常' : connectionStatus === 'error' ? '服务异常' : '未检测'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
{availableModels.length > 0
|
||||||
|
? `已安装 ${availableModels.length} 个模型`
|
||||||
|
: connectionStatus === 'success'
|
||||||
|
? '未安装模型'
|
||||||
|
: '请先连接服务'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card bordered={false} style={{ boxShadow: 'none' }}>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
服务配置
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>Ollama 服务地址</span>}
|
||||||
|
name="ollamaUrl"
|
||||||
|
rules={[{ required: true, message: '请输入Ollama服务地址' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: '16px' }}>
|
||||||
|
<Button
|
||||||
|
icon={<ApiOutlined />}
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
loading={testing}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
测试连接
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={handleDetectModels}
|
||||||
|
loading={detectingModels}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
检测模型
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
模型选择
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<Space>
|
||||||
|
<span style={{ color: '#595959' }}>AI 模型</span>
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<Tag color="success">{availableModels.length} 个可用</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
name="model"
|
||||||
|
rules={[{
|
||||||
|
required: true,
|
||||||
|
message: '请选择AI模型'
|
||||||
|
}]}
|
||||||
|
tooltip="只能选择本地已安装的模型"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={availableModels.length === 0 ? "请先点击检测模型" : "选择AI模型"}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
onChange={handleModelChange}
|
||||||
|
notFoundContent={detectingModels ? <Spin size="small" /> : "未检测到可用模型"}
|
||||||
|
disabled={availableModels.length === 0}
|
||||||
|
style={{ borderRadius: '6px' }}
|
||||||
|
>
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<Option key={model.name} value={model.name}>
|
||||||
|
<Space>
|
||||||
|
<span>{model.name}</span>
|
||||||
|
{model.size && (
|
||||||
|
<span style={{ color: '#8c8c8c', fontSize: '12px' }}>
|
||||||
|
({formatModelSize(model.size)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{currentModel && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
background: '#f6ffed',
|
||||||
|
border: '1px solid #b7eb8f',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<Space>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||||
|
<span style={{ color: '#52c41a', fontSize: '14px' }}>
|
||||||
|
当前使用模型: {currentModel}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
生成参数
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>温度</span>}
|
||||||
|
name="temperature"
|
||||||
|
rules={[{ required: true, message: '请输入温度值' }]}
|
||||||
|
tooltip="控制生成文本的随机性"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
precision={1}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>Top P</span>}
|
||||||
|
name="topP"
|
||||||
|
rules={[{ required: true, message: '请输入Top P值' }]}
|
||||||
|
tooltip="控制生成文本的多样性"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
precision={1}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<span style={{ color: '#595959' }}>最大 Tokens</span>}
|
||||||
|
name="maxTokens"
|
||||||
|
rules={[{ required: true, message: '请输入最大生成长度' }]}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={100}
|
||||||
|
max={8000}
|
||||||
|
step={100}
|
||||||
|
style={{ width: '100%', borderRadius: '6px' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item style={{ marginBottom: 0 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
style={{ borderRadius: '6px', minWidth: '120px' }}
|
||||||
|
>
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '32px 0' }} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#262626',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
使用说明
|
||||||
|
</div>
|
||||||
|
<ul style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingLeft: '20px',
|
||||||
|
color: '#595959',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.8'
|
||||||
|
}}>
|
||||||
|
<li>确保 Ollama 服务正在运行(默认端口 11434)</li>
|
||||||
|
<li>点击"测试连接"验证 Ollama 服务是否正常</li>
|
||||||
|
<li>点击"检测模型"获取本地已安装的模型列表</li>
|
||||||
|
<li>模型选择限制为只能使用本地已安装的模型</li>
|
||||||
|
<li>如需新模型,请使用命令:ollama pull 模型名</li>
|
||||||
|
<li>推荐:qwen3:8b起步</li>
|
||||||
|
<li>温度参数控制生成文本的随机性,建议值为 0.7</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemConfig;
|
||||||
810
src/pages/UserManual.tsx
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Typography, Divider, Space, Tag, List, Steps, Alert, Tabs } from 'antd';
|
||||||
|
import { BookOutlined, SettingOutlined, RobotOutlined, EditOutlined, AppstoreOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
|
const UserManual: React.FC = () => {
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'overview',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<RobotOutlined /> 系统概述
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>AI 小说创作系统概述</Title>
|
||||||
|
<Paragraph>
|
||||||
|
AI 小说创作系统是一个基于人工智能的辅助创作平台,通过本地部署的 Ollama 模型帮助用户完成小说创作的各个环节。
|
||||||
|
系统提供从小说创建、设定生成、章节创作到完整作品管理的全流程支持。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={4}>主要功能</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
'智能小说设定生成(角色、世界观、故事线)',
|
||||||
|
'分章节内容创作与续写',
|
||||||
|
'本地化部署,数据安全可控',
|
||||||
|
'多项目管理,便于同时处理多个作品',
|
||||||
|
'灵活的参数配置,适应不同创作风格'
|
||||||
|
]}
|
||||||
|
renderItem={(item: string) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>{item}</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'model',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<SettingOutlined /> 模型配置指南
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
message="重要提示"
|
||||||
|
description="配置模型是使用本系统的第一步,必须先完成模型配置才能使用 AI 创作功能。"
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '16px' }}>
|
||||||
|
<Title level={4}>1. Ollama 服务安装</Title>
|
||||||
|
<Paragraph>
|
||||||
|
首先需要在本地安装 Ollama 服务,这是系统运行的基础。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>安装步骤:</Title>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={-1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '下载 Ollama',
|
||||||
|
description: '访问 Ollama 官网(https://ollama.ai)下载适合您操作系统的版本'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '安装 Ollama',
|
||||||
|
description: '运行安装程序,按提示完成安装'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '启动服务',
|
||||||
|
description: '在终端中运行 ollama serve 命令启动服务'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '验证安装',
|
||||||
|
description: '确认服务运行在默认端口 11434'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Title level={4}>2. 模型安装与选择</Title>
|
||||||
|
<Paragraph>
|
||||||
|
安装完 Ollama 后,需要安装适合的 AI 模型用于小说创作。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>推荐模型配置:</Title>
|
||||||
|
<Space direction="vertical" style={{ width: '100%', marginBottom: '16px' }}>
|
||||||
|
<div style={{ padding: '12px', background: '#f6ffed', borderRadius: '6px', border: '1px solid #b7eb8f' }}>
|
||||||
|
<div>
|
||||||
|
<Tag color="green">推荐配置</Tag>
|
||||||
|
<Text strong>qwen3:8b</Text>
|
||||||
|
<Text type="secondary"> - 8B 参数,质量和速度平衡,适合大多数创作需求</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text code>ollama pull qwen3:8b</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '12px', background: '#e6f7ff', borderRadius: '6px', border: '1px solid #91d5ff' }}>
|
||||||
|
<div>
|
||||||
|
<Tag color="blue">进阶配置</Tag>
|
||||||
|
<Text strong>qwen3:14b</Text>
|
||||||
|
<Text type="secondary"> - 14B 参数,生成质量更高,需要更多内存</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text code>ollama pull qwen3:14b</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '12px', background: '#fff9e6', borderRadius: '6px', border: '1px solid #ffe58f' }}>
|
||||||
|
<div>
|
||||||
|
<Tag color="purple">高级配置</Tag>
|
||||||
|
<Text strong>qwen3:32b</Text>
|
||||||
|
<Text type="secondary"> - 32B 参数,最佳创作效果,需要较好的硬件配置</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text code>ollama pull qwen3:32b</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Title level={5}>其他可选模型:</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
<div>
|
||||||
|
<Text strong>llama3:8b</Text> - Meta 开源的 Llama 3 模型,英文表现优秀
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>mistral:7b</Text> - Mistral AI 开发的模型,推理能力强
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>gemma:7b</Text> - Google 开发的 Gemma 模型
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
renderItem={(item: React.ReactNode) => (
|
||||||
|
<List.Item>
|
||||||
|
{item}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Title level={4}>3. 系统配置参数详解</Title>
|
||||||
|
<Paragraph>
|
||||||
|
在"设置 → 模型"页面中,您可以配置以下关键参数来优化 AI 创作效果:
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>服务配置参数:</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
<div>
|
||||||
|
<Text strong>Ollama 服务地址:</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">默认地址:http://localhost:11434</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px', padding: '8px', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<Text code>http://localhost:11434</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">如果 Ollama 安装在其他机器或端口,请相应修改地址</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>AI 模型选择:</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">只能选择本地已安装的模型,需要先点击"检测模型"按钮</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px', padding: '8px', background: '#fffbe6', borderRadius: '4px', border: '1px solid #ffe58f' }}>
|
||||||
|
<Text strong>注意:</Text>如果模型列表为空,请先用命令行安装模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
renderItem={(item: React.ReactNode) => (
|
||||||
|
<List.Item>
|
||||||
|
{item}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title level={5}>生成参数配置:</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
<div>
|
||||||
|
<Text strong>温度参数:</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">范围:0-2,推荐值:0.7</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">• 低值(0.3-0.5):生成内容更加确定、保守</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">• 中值(0.6-0.8):平衡创造性和连贯性(推荐)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">• 高值(0.9-1.2):内容更加随机、创新</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>Top P 参数:</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">范围:0-1,推荐值:0.9</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">控制生成文本的多样性,值越高内容越多样</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>最大 Tokens:</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">范围:100-8000,推荐值:2000</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">控制单次生成的最大长度,1 Token ≈ 0.75 个中文字符</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
renderItem={(item: React.ReactNode) => (
|
||||||
|
<List.Item>
|
||||||
|
{item}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Title level={4}>4. 配置验证完整流程</Title>
|
||||||
|
<Alert
|
||||||
|
message="配置验证步骤"
|
||||||
|
description="请按顺序完成以下步骤,确保每个步骤都成功后再进行下一步"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={-1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '步骤 1:测试 Ollama 服务连接',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"测试连接"按钮验证 Ollama 服务状态</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:Ollama 服务连接成功!</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#fff1f0', borderRadius: '4px' }}>
|
||||||
|
<Text>失败提示:请检查 ollama serve 是否正在运行</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 2:检测已安装模型',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"检测模型"按钮获取本地已安装的模型列表</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:成功检测到 N 个已安装模型</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#fffbe6', borderRadius: '4px' }}>
|
||||||
|
<Text>警告提示:未检测到已安装的模型,请先使用 ollama pull 命令安装模型</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 3:选择 AI 模型',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>从下拉列表中选择要使用的模型</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary">• 模型列表显示:模型名称(模型大小)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">• 选择已安装的模型,系统会显示当前使用模型信息</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 4:调整生成参数',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>根据您的创作需求调整参数</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary">• 初次使用建议保持默认参数</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">• 有经验后可以根据题材特点微调参数</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 5:保存配置',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"保存配置"按钮完成设置</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:配置保存成功!</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary">保存后系统会自动刷新状态,页面底部会显示当前配置信息</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'novel',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<EditOutlined /> 小说管理功能
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
message="核心功能"
|
||||||
|
description="小说管理是本系统的核心功能,支持从创意到成稿的完整创作流程。"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '16px' }}>
|
||||||
|
<Title level={4}>1. 创建新小说(第一步)</Title>
|
||||||
|
<Paragraph>
|
||||||
|
开始创作前,首先需要创建一个新的小说项目。这是整个创作流程的起点。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>详细步骤:</Title>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={-1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '步骤 1:进入小说管理页面',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击左侧菜单的"小说管理"进入小说列表页面</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||||
|
<Text type="secondary">📍 位置:左侧菜单 → 第一项"小说管理"</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 2:点击"新建小说"按钮',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>在页面右上角找到蓝色的"新建小说"按钮</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||||
|
<Text type="secondary">📍 位置:页面右上角,带有 ➕ 图标的蓝色按钮</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 3:填写小说基本信息',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>在弹出的对话框中填写以下信息:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>书名:</Text>
|
||||||
|
<Text type="secondary">为您的小说起一个吸引人的名字(必填)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>题材类型:</Text>
|
||||||
|
<Text type="secondary">从下拉列表中选择题材(必填)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>初步想法:</Text>
|
||||||
|
<Text type="secondary">简单描述您的创作概念,可留空让 AI 帮助完善(选填)</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 4:确认创建',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"确定"按钮完成小说创建</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:小说创建成功,请前往AI生成页面完善设定</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title level={5}>题材选项说明:</Title>
|
||||||
|
<List
|
||||||
|
grid={{ gutter: 16, column: 3 }}
|
||||||
|
dataSource={[
|
||||||
|
'穿越', '都市', '修仙', '武侠', '玄幻',
|
||||||
|
'科幻', '言情', '历史', '游戏', '灵异', '军事', '悬疑', '其他'
|
||||||
|
]}
|
||||||
|
renderItem={(item: string) => (
|
||||||
|
<List.Item>
|
||||||
|
<Tag color="blue">{item}</Tag>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '16px' }}>
|
||||||
|
<Title level={4}>2. 完善小说设定(第二步)</Title>
|
||||||
|
<Paragraph>
|
||||||
|
创建小说后,下一步是完善小说的详细设定。这是创作高质量小说的基础。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>详细步骤:</Title>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={-1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '步骤 1:进入 AI 生成页面',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>在小说列表中找到刚创建的小说,点击卡片进入详情页</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text type="secondary">或者:点击卡片上的"编辑"按钮,然后选择"完善设定"</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#fffbe6', borderRadius: '4px', border: '1px solid #ffe58f' }}>
|
||||||
|
<Text strong>⚠️ 重要:</Text>如果未完善设定,会显示黄色警告提示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 2:配置小说参数',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>在 AI 生成页面配置以下参数:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>小说名称:</Text>
|
||||||
|
<Text type="secondary">自动显示创建时的书名(不可修改)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>题材类型:</Text>
|
||||||
|
<Text type="secondary">自动显示创建时的题材(不可修改)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>目标字数:</Text>
|
||||||
|
<Text type="secondary">选择小说总字数:5万字、10万字、20万字、30万字、50万字、100万字</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>特殊要求:</Text>
|
||||||
|
<Text type="secondary">描述您对这部小说的特殊要求或想法(选填)</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 3:AI 生成基础设定',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"AI生成设定"按钮,AI 将为您生成:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>📖 故事大纲:</Text>
|
||||||
|
<Text type="secondary">整个故事的核心情节和主题(150字以内)</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>🏗️ 情节结构:</Text>
|
||||||
|
<Text type="secondary">按模块划分整个故事的结构</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>👥 人物设定:</Text>
|
||||||
|
<Text type="secondary">主要人物的详细设定,包括主角、配角、反派</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:基础设定生成成功!现在可以开始手动生成章节标题和细纲。</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 4:生成章节规划',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>基础设定生成后,可以开始生成章节标题和细纲:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>单个生成:</Text>
|
||||||
|
<Text type="secondary">点击"生成下一章标题+细纲"逐章生成</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>批量生成:</Text>
|
||||||
|
<Text type="secondary">点击"批量生成5章标题+细纲"一次生成多个章节</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>单独生成:</Text>
|
||||||
|
<Text type="secondary">直接点击章节卡片单独生成某章的标题和细纲</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||||
|
<Text type="secondary">💡 提示:章节规划包含章节标题、细纲、预计字数等信息</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 5:编辑和确认设定',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>生成的内容可以手动编辑:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>编辑设定:</Text>
|
||||||
|
<Text type="secondary">点击"编辑设定"按钮修改故事大纲、情节结构、人物设定</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>编辑章节:</Text>
|
||||||
|
<Text type="secondary">点击章节卡片的"编辑"按钮修改章节标题和细纲</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>确认保存:</Text>
|
||||||
|
<Text type="secondary">确认无误后点击"确认并保存"按钮</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '16px' }}>
|
||||||
|
<Title level={4}>3. 章节内容创作(第三步)</Title>
|
||||||
|
<Paragraph>
|
||||||
|
完成设定后,就可以开始具体的章节内容创作了。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Title level={5}>详细步骤:</Title>
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
current={-1}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: '步骤 1:进入小说详情页',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>点击"前往创作"按钮或返回小说列表,点击小说卡片</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f0f9ff', borderRadius: '4px' }}>
|
||||||
|
<Text type="secondary">📍 位置:小说列表 → 点击小说卡片</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 2:查看章节规划',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>在"章节列表"中可以看到所有已规划的章节:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Tag color="green">绿色边框</Tag>
|
||||||
|
<Text type="secondary">:已生成标题和细纲的章节</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Tag color="orange">黄色边框</Tag>
|
||||||
|
<Text type="secondary">:待生成标题和细纲的章节</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Tag color="blue">蓝色边框</Tag>
|
||||||
|
<Text type="secondary">:已创作内容的章节</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 3:生成章节内容',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>选择一个已规划标题和细纲的章节,点击"AI生成"按钮:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>生成过程:</Text>
|
||||||
|
<Text type="secondary">AI 会基于小说设定和章节规划生成具体内容</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>生成时间:</Text>
|
||||||
|
<Text type="secondary">大约需要10-30秒,请耐心等待</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>内容质量:</Text>
|
||||||
|
<Text type="secondary">生成的内容约900-1200字,符合章节规划要求</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', background: '#f6ffed', borderRadius: '4px' }}>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: '4px' }} />
|
||||||
|
<Text>成功提示:第X章生成成功</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 4:查看和编辑内容',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>生成完成后,可以查看和编辑内容:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>查看内容:</Text>
|
||||||
|
<Text type="secondary">点击"查看内容"按钮查看生成的章节内容</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>手动编辑:</Text>
|
||||||
|
<Text type="secondary">可以对AI生成的内容进行手动修改和润色</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>重新生成:</Text>
|
||||||
|
<Text type="secondary">如果不满意,可以删除后重新生成</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '步骤 5:继续创作其他章节',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>重复步骤3-4,完成所有章节的创作:</div>
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<Text strong>建议顺序:</Text>
|
||||||
|
<Text type="secondary">按章节顺序从第1章开始创作</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>批量创作:</Text>
|
||||||
|
<Text type="secondary">可以连续创作多个章节,保持创作连贯性</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text strong>定期保存:</Text>
|
||||||
|
<Text type="secondary">系统会自动保存,但建议定期手动确认</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Title level={5}>章节管理功能:</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
<div>
|
||||||
|
<Text strong>查看章节:</Text>
|
||||||
|
<Text type="secondary">点击章节卡片的"查看内容"按钮查看完整章节内容</Text>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>编辑章节:</Text>
|
||||||
|
<Text type="secondary">手动修改AI生成的内容,添加自己的创意和风格</Text>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>删除章节:</Text>
|
||||||
|
<Text type="secondary">删除不满意的章节,重新生成</Text>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>下载章节:</Text>
|
||||||
|
<Text type="secondary">将单个章节导出为 Markdown 文件</Text>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>下载整书:</Text>
|
||||||
|
<Text type="secondary">将所有章节合并导出为完整的 Markdown 文件</Text>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>查看规划:</Text>
|
||||||
|
<Text type="secondary">查看章节的标题、细纲、预计字数等规划信息</Text>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
renderItem={(item: React.ReactNode) => (
|
||||||
|
<List.Item>
|
||||||
|
{item}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tips',
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<AppstoreOutlined /> 使用技巧
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>提高创作质量的技巧</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
'充分完善设定:详细的角色和世界观设定有助于生成更连贯的内容',
|
||||||
|
'逐步生成章节:不要一次性生成太多内容,分章节逐步完善质量更高',
|
||||||
|
'适当手动编辑:AI生成后进行手动修改,提升整体质量和风格统一性',
|
||||||
|
'参数调优:根据不同题材调整温度和Top P参数,找到最佳配置',
|
||||||
|
'保持风格一致:在续写时参考前文风格,保持整本小说的风格统一',
|
||||||
|
'定期备份:虽然系统有自动保存,但重要节点建议手动导出备份'
|
||||||
|
]}
|
||||||
|
renderItem={(item: string) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>{item}</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Title level={3}>常见问题解决</Title>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
<div>
|
||||||
|
<Text strong>问题:生成内容不符合预期</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">解决:重新编辑章节细纲,提供更详细的指导,然后重新生成</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>问题:章节之间连贯性差</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">解决:手动编辑前一章的结尾,为下一章做铺垫,然后重新生成</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>问题:人物性格不一致</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">解决:完善人物设定,详细描述性格特点,并在章节细纲中强调</Text>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div>
|
||||||
|
<Text strong>问题:创作进度缓慢</Text>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
<Text type="secondary">解决:使用批量生成功能,但要注意定期检查质量</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
renderItem={(item: React.ReactNode) => (
|
||||||
|
<List.Item>
|
||||||
|
{item}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
|
||||||
|
<div style={{ marginBottom: '32px', textAlign: 'center' }}>
|
||||||
|
<Title level={2} style={{ color: '#1890ff', marginBottom: '8px' }}>
|
||||||
|
<BookOutlined /> AI 小说创作系统使用手册
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">完整的系统功能使用指南</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="overview"
|
||||||
|
items={tabItems}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: '24px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Title level={3}>技术支持</Title>
|
||||||
|
<Paragraph>
|
||||||
|
如遇到问题或需要更多帮助,请参考:
|
||||||
|
</Paragraph>
|
||||||
|
<List
|
||||||
|
dataSource={[
|
||||||
|
'Ollama 官方文档:https://github.com/ollama/ollama',
|
||||||
|
'Qwen 模型文档:https://huggingface.co/Qwen',
|
||||||
|
'系统反馈:通过底部联系信息反馈问题'
|
||||||
|
]}
|
||||||
|
renderItem={(item: string) => (
|
||||||
|
<List.Item>
|
||||||
|
<Text>{item}</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Paragraph style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||||
|
AI 小说创作系统 v0.0.1 | 代码老中医出品
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserManual;
|
||||||
1
src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
||||||
15
src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReportHandler } from 'web-vitals';
|
||||||
|
|
||||||
|
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
5
src/setupTests.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
61
src/types/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface SystemConfig {
|
||||||
|
ollamaUrl: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
maxTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节规划信息
|
||||||
|
export interface ChapterOutline {
|
||||||
|
chapterNumber: number;
|
||||||
|
title: string;
|
||||||
|
outline: string;
|
||||||
|
moduleNumber?: number; // 所属模块
|
||||||
|
estimatedWords?: number; // 预计字数
|
||||||
|
status?: 'pending' | 'completed'; // 章节状态:待生成或已完成
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Novel {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
genre: string;
|
||||||
|
outline: string;
|
||||||
|
targetWordCount?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
// AI生成的详细设定
|
||||||
|
generatedSettings?: {
|
||||||
|
storyOutline: string;
|
||||||
|
plotStructure: string;
|
||||||
|
characters: string;
|
||||||
|
targetWordCount: number;
|
||||||
|
chapterCount: number;
|
||||||
|
moduleCount: number; // 模块数量
|
||||||
|
chapters: ChapterOutline[]; // 详细章节规划
|
||||||
|
chapterOutline: string; // 原始章节大纲文本
|
||||||
|
};
|
||||||
|
// 本地文件路径
|
||||||
|
settingFilePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
id: string;
|
||||||
|
novelId: string;
|
||||||
|
chapterNumber: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
outline: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
estimatedWords?: number;
|
||||||
|
moduleNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NovelGenerationParams {
|
||||||
|
title: string;
|
||||||
|
genre?: string;
|
||||||
|
customRequirements?: string;
|
||||||
|
targetWordCount?: number; // 目标字数
|
||||||
|
preferredModuleCount?: number; // 偏好的模块数量
|
||||||
|
}
|
||||||
279
src/utils/indexedDB.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { SystemConfig, Novel, Chapter } from '../types';
|
||||||
|
|
||||||
|
const DB_NAME = 'AINovelDB';
|
||||||
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
|
class IndexedDBStorage {
|
||||||
|
private db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error('Failed to open IndexedDB'));
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// 创建小说存储
|
||||||
|
if (!db.objectStoreNames.contains('novels')) {
|
||||||
|
const novelStore = db.createObjectStore('novels', { keyPath: 'id' });
|
||||||
|
novelStore.createIndex('createdAt', 'createdAt', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建章节存储
|
||||||
|
if (!db.objectStoreNames.contains('chapters')) {
|
||||||
|
const chapterStore = db.createObjectStore('chapters', { keyPath: 'id' });
|
||||||
|
chapterStore.createIndex('novelId', 'novelId', { unique: false });
|
||||||
|
chapterStore.createIndex('chapterNumber', 'chapterNumber', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建系统配置存储
|
||||||
|
if (!db.objectStoreNames.contains('config')) {
|
||||||
|
db.createObjectStore('config', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): Promise<IDBObjectStore> {
|
||||||
|
if (!this.db) {
|
||||||
|
await this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果数据库连接失败,尝试重新初始化
|
||||||
|
if (!this.db) {
|
||||||
|
throw new Error('Database initialization failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查请求的存储是否存在
|
||||||
|
if (!this.db.objectStoreNames.contains(storeName)) {
|
||||||
|
console.error(`Object store '${storeName}' not found in database`);
|
||||||
|
// 尝试重新初始化数据库以创建缺失的存储
|
||||||
|
await this.reinit();
|
||||||
|
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
|
||||||
|
throw new Error(`Object store '${storeName}' not found after reinitialization`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = this.db.transaction(storeName, mode);
|
||||||
|
return transaction.objectStore(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reinit(): Promise<void> {
|
||||||
|
// 关闭当前连接
|
||||||
|
if (this.db) {
|
||||||
|
this.db.close();
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧数据库并重新创建
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deleteReq = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
deleteReq.onsuccess = () => {
|
||||||
|
console.log('Old database deleted successfully');
|
||||||
|
this.init().then(resolve).catch(reject);
|
||||||
|
};
|
||||||
|
deleteReq.onerror = () => {
|
||||||
|
console.error('Failed to delete old database');
|
||||||
|
reject(new Error('Failed to delete old database'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 小说操作
|
||||||
|
async getNovels(): Promise<Novel[]> {
|
||||||
|
const store = await this.getStore('novels');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result || []);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addNovel(novel: Novel): Promise<void> {
|
||||||
|
const store = await this.getStore('novels', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.add(novel);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNovel(id: string, updates: Partial<Novel>): Promise<void> {
|
||||||
|
const novels = await this.getNovels();
|
||||||
|
const index = novels.findIndex(n => n.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const updatedNovel = { ...novels[index], ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
const store = await this.getStore('novels', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.put(updatedNovel);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNovel(id: string): Promise<void> {
|
||||||
|
const store = await this.getStore('novels', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.delete(id);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节操作
|
||||||
|
async getChapters(novelId: string): Promise<Chapter[]> {
|
||||||
|
const store = await this.getStore('chapters');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const allChapters = request.result || [];
|
||||||
|
resolve(allChapters.filter(c => c.novelId === novelId));
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllChapters(): Promise<Chapter[]> {
|
||||||
|
const store = await this.getStore('chapters');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result || []);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addChapter(chapter: Chapter): Promise<void> {
|
||||||
|
const store = await this.getStore('chapters', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.add(chapter);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChapter(id: string, updates: Partial<Chapter>): Promise<void> {
|
||||||
|
const chapters = await this.getAllChapters();
|
||||||
|
const index = chapters.findIndex(c => c.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const updatedChapter = { ...chapters[index], ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
const store = await this.getStore('chapters', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.put(updatedChapter);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChapter(id: string): Promise<void> {
|
||||||
|
const store = await this.getStore('chapters', 'readwrite');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.delete(id);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统配置操作
|
||||||
|
async getSystemConfig(): Promise<SystemConfig> {
|
||||||
|
const store = await this.getStore('config');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const request = store.get('system');
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const config = request.result;
|
||||||
|
if (config) {
|
||||||
|
delete config.id;
|
||||||
|
resolve(config);
|
||||||
|
} else {
|
||||||
|
// 返回默认配置
|
||||||
|
resolve({
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
resolve({
|
||||||
|
ollamaUrl: 'http://localhost:11434',
|
||||||
|
model: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
topP: 0.9,
|
||||||
|
maxTokens: 2000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSystemConfig(config: SystemConfig): Promise<void> {
|
||||||
|
const store = await this.getStore('config', 'readwrite');
|
||||||
|
const data = { id: 'system', ...config };
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = store.put(data);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const indexedDBStorage = new IndexedDBStorage();
|
||||||
|
|
||||||
|
// 调试工具:检查数据库状态
|
||||||
|
export const debugDB = async () => {
|
||||||
|
try {
|
||||||
|
await indexedDBStorage.init();
|
||||||
|
const novels = await indexedDBStorage.getNovels();
|
||||||
|
const chapters = await indexedDBStorage.getAllChapters();
|
||||||
|
const config = await indexedDBStorage.getSystemConfig();
|
||||||
|
|
||||||
|
console.log('=== IndexedDB 调试信息 ===');
|
||||||
|
console.log('小说数量:', novels.length);
|
||||||
|
console.log('章节数量:', chapters.length);
|
||||||
|
console.log('系统配置:', config);
|
||||||
|
console.log('========================');
|
||||||
|
|
||||||
|
return {
|
||||||
|
novels,
|
||||||
|
chapters,
|
||||||
|
config,
|
||||||
|
status: 'success'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('IndexedDB 调试失败:', error);
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 兼容旧代码的 storage 对象
|
||||||
|
export const storage = {
|
||||||
|
getNovels: () => indexedDBStorage.getNovels(),
|
||||||
|
addNovel: (novel: Novel) => indexedDBStorage.addNovel(novel),
|
||||||
|
updateNovel: (id: string, updates: Partial<Novel>) => indexedDBStorage.updateNovel(id, updates),
|
||||||
|
deleteNovel: (id: string) => indexedDBStorage.deleteNovel(id),
|
||||||
|
|
||||||
|
getChapters: (novelId: string) => indexedDBStorage.getChapters(novelId),
|
||||||
|
getAllChapters: () => indexedDBStorage.getAllChapters(),
|
||||||
|
addChapter: (chapter: Chapter) => indexedDBStorage.addChapter(chapter),
|
||||||
|
updateChapter: (id: string, updates: Partial<Chapter>) => indexedDBStorage.updateChapter(id, updates),
|
||||||
|
deleteChapter: (id: string) => indexedDBStorage.deleteChapter(id),
|
||||||
|
|
||||||
|
getSystemConfig: () => indexedDBStorage.getSystemConfig(),
|
||||||
|
saveSystemConfig: (config: SystemConfig) => indexedDBStorage.saveSystemConfig(config)
|
||||||
|
};
|
||||||
268
src/utils/novelSettingManager.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { Novel } from '../types';
|
||||||
|
|
||||||
|
export class NovelSettingManager {
|
||||||
|
/**
|
||||||
|
* 生成小说设置文件内容(优化版)
|
||||||
|
*/
|
||||||
|
static generateSettingContent(title: string, settings: any): string {
|
||||||
|
let content = `# ${title} - 小说设定文件\n\n`;
|
||||||
|
content += `生成时间:${new Date().toLocaleString()}\n\n`;
|
||||||
|
content += `---\n\n`;
|
||||||
|
|
||||||
|
if (settings.storyOutline) {
|
||||||
|
content += `## 📖 故事大纲\n\n${settings.storyOutline}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.plotStructure) {
|
||||||
|
content += `## 🎯 情节结构\n\n${settings.plotStructure}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.characters) {
|
||||||
|
content += `## 👥 人物设定\n\n${settings.characters}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `## 📊 篇幅规划\n\n`;
|
||||||
|
content += `- 目标字数:${settings.targetWordCount}字\n`;
|
||||||
|
content += `- 章节数量:${settings.chapterCount}章\n`;
|
||||||
|
content += `- 每章字数:${Math.round(settings.targetWordCount / settings.chapterCount)}字\n\n`;
|
||||||
|
|
||||||
|
// 增加详细的章节规划信息
|
||||||
|
if (settings.chapters && settings.chapters.length > 0) {
|
||||||
|
content += `## 📚 详细章节规划\n\n`;
|
||||||
|
content += `以下是全书${settings.chapters.length}个章节的详细规划,每章包含标题、细纲、预计字数等信息。\n\n`;
|
||||||
|
|
||||||
|
// 按模块分组显示章节
|
||||||
|
const moduleGroups: { [key: number]: any[] } = {};
|
||||||
|
settings.chapters.forEach((chapter: any) => {
|
||||||
|
const moduleNum = chapter.moduleNumber || 1;
|
||||||
|
if (!moduleGroups[moduleNum]) {
|
||||||
|
moduleGroups[moduleNum] = [];
|
||||||
|
}
|
||||||
|
moduleGroups[moduleNum].push(chapter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输出每个模块的章节
|
||||||
|
Object.keys(moduleGroups).sort((a, b) => parseInt(a) - parseInt(b)).forEach(moduleNum => {
|
||||||
|
content += `### 模块${moduleNum}\n\n`;
|
||||||
|
moduleGroups[parseInt(moduleNum)].forEach((chapter: any) => {
|
||||||
|
content += `#### 第${chapter.chapterNumber}章:${chapter.title}\n\n`;
|
||||||
|
content += `**所属模块**:模块${chapter.moduleNumber || 1}\n\n`;
|
||||||
|
content += `**章节细纲**:\n${chapter.outline}\n\n`;
|
||||||
|
content += `**预计字数**:${chapter.estimatedWords || 1050}字\n\n`;
|
||||||
|
content += `---\n\n`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.chapterOutline) {
|
||||||
|
content += `## 📚 原始章节细纲\n\n${settings.chapterOutline}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += `---\n\n`;
|
||||||
|
content += `*此文件由AI创作助手自动生成,包含小说的核心设定信息和详细章节规划。在创作各章节时请严格遵循本设定的要求。*\n`;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成并保存设置文件
|
||||||
|
*/
|
||||||
|
static async generateAndSaveSettingFile(title: string, settings: any): Promise<string> {
|
||||||
|
try {
|
||||||
|
const content = this.generateSettingContent(title, settings);
|
||||||
|
const filename = `${title}_novel_setting.md`;
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存设置文件失败:', error);
|
||||||
|
throw new Error('保存设置文件失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件内容解析设置
|
||||||
|
*/
|
||||||
|
static parseSettingFile(content: string): any {
|
||||||
|
const settings: any = {
|
||||||
|
storyOutline: '',
|
||||||
|
plotStructure: '',
|
||||||
|
characters: '',
|
||||||
|
targetWordCount: 50000,
|
||||||
|
chapterCount: 20,
|
||||||
|
chapterOutline: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let currentSection = '';
|
||||||
|
let sectionContent = '';
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.startsWith('## ')) {
|
||||||
|
// 保存上一个section
|
||||||
|
if (currentSection && sectionContent.trim()) {
|
||||||
|
switch(currentSection) {
|
||||||
|
case '📖 故事大纲':
|
||||||
|
case '故事大纲':
|
||||||
|
settings.storyOutline = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '🎯 情节结构':
|
||||||
|
case '情节结构':
|
||||||
|
settings.plotStructure = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '👥 人物设定':
|
||||||
|
case '人物设定':
|
||||||
|
settings.characters = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '📚 章节细纲':
|
||||||
|
case '章节细纲':
|
||||||
|
settings.chapterOutline = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSection = line.replace('## ', '').replace(/[📖🎯👥📚]/g, '').trim();
|
||||||
|
sectionContent = '';
|
||||||
|
} else if (line.trim() && !line.startsWith('---') && !line.startsWith('# ') && !line.startsWith('*')) {
|
||||||
|
sectionContent += line + '\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理最后一个section
|
||||||
|
if (currentSection && sectionContent.trim()) {
|
||||||
|
switch(currentSection) {
|
||||||
|
case '故事大纲':
|
||||||
|
settings.storyOutline = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '情节结构':
|
||||||
|
settings.plotStructure = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '人物设定':
|
||||||
|
settings.characters = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
case '章节细纲':
|
||||||
|
settings.chapterOutline = sectionContent.trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取数字信息
|
||||||
|
const wordCountMatch = content.match(/目标字数[::]\s*(\d+)/);
|
||||||
|
if (wordCountMatch) {
|
||||||
|
settings.targetWordCount = parseInt(wordCountMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterCountMatch = content.match(/章节数量[::]\s*(\d+)/);
|
||||||
|
if (chapterCountMatch) {
|
||||||
|
settings.chapterCount = parseInt(chapterCountMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成章节上下文信息(优化版)
|
||||||
|
*/
|
||||||
|
static generateChapterContext(
|
||||||
|
novel: Novel,
|
||||||
|
currentChapter: number,
|
||||||
|
previousChapters: any[],
|
||||||
|
settings: any,
|
||||||
|
chapterTitle?: string,
|
||||||
|
chapterOutline?: string
|
||||||
|
): string {
|
||||||
|
let context = `# 《${novel.title}》第${currentChapter}章创作指南\n\n`;
|
||||||
|
|
||||||
|
// 第一步:novel_setting.md的核心设定
|
||||||
|
context += `## 📋 核心设定(必须严格遵守)\n\n`;
|
||||||
|
|
||||||
|
if (settings.storyOutline) {
|
||||||
|
context += `### 📖 故事大纲\n${settings.storyOutline}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.plotStructure) {
|
||||||
|
context += `### 🏗️ 情节结构\n${settings.plotStructure}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.characters) {
|
||||||
|
context += `### 👥 人物设定\n${settings.characters}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每10章重新强调核心设定
|
||||||
|
if (currentChapter % 10 === 1) {
|
||||||
|
context += `⚠️ **重要提醒**:这是第${currentChapter}章,属于新一轮章节的开始。请特别重视并严格遵守上述核心设定,确保人物性格、能力、背景等保持一致,不偏离主线。\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
context += `---\n\n`;
|
||||||
|
|
||||||
|
// 第二步:当前章节的标题和细纲
|
||||||
|
context += `## 📝 当前章节创作要求\n\n`;
|
||||||
|
context += `**章节编号**:第${currentChapter}章\n\n`;
|
||||||
|
|
||||||
|
if (chapterTitle) {
|
||||||
|
context += `**章节标题**:${chapterTitle}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chapterOutline) {
|
||||||
|
context += `**章节细纲**:\n${chapterOutline}\n\n`;
|
||||||
|
} else if (settings.chapters && settings.chapters[currentChapter - 1]) {
|
||||||
|
// 从预设章节数据中获取细纲
|
||||||
|
const plannedChapter = settings.chapters[currentChapter - 1];
|
||||||
|
context += `**章节标题**:${plannedChapter.title}\n\n`;
|
||||||
|
context += `**章节细纲**:\n${plannedChapter.outline}\n\n`;
|
||||||
|
context += `**预计字数**:${plannedChapter.estimatedWords || 1050}字\n\n`;
|
||||||
|
if (plannedChapter.moduleNumber) {
|
||||||
|
context += `**所属模块**:模块${plannedChapter.moduleNumber}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context += `---\n\n`;
|
||||||
|
|
||||||
|
// 第三步:最近一章的内容(保持上下文连贯性)
|
||||||
|
if (previousChapters.length > 0) {
|
||||||
|
const lastChapter = previousChapters[previousChapters.length - 1];
|
||||||
|
context += `## 📖 上一章内容回顾(用于衔接)\n\n`;
|
||||||
|
context += `**第${lastChapter.chapterNumber}章 ${lastChapter.title}**\n\n`;
|
||||||
|
|
||||||
|
// 截取上一章的完整内容,但限制长度避免token过多
|
||||||
|
const lastContent = lastChapter.content.length > 2000
|
||||||
|
? lastChapter.content.substring(0, 2000) + "..."
|
||||||
|
: lastChapter.content;
|
||||||
|
|
||||||
|
context += `${lastContent}\n\n`;
|
||||||
|
context += `*(以上是第${lastChapter.chapterNumber}章的完整内容,请确保第${currentChapter}章与其自然衔接)*\n\n`;
|
||||||
|
context += `---\n\n`;
|
||||||
|
} else {
|
||||||
|
context += `## 📖 创作说明\n\n`;
|
||||||
|
context += `这是小说的第一章,请开篇设定好场景,引入主要人物和核心冲突,为后续情节发展做好铺垫。\n\n`;
|
||||||
|
context += `---\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第四步:创作要求
|
||||||
|
const targetWords = Math.round(settings.targetWordCount / settings.chapterCount);
|
||||||
|
context += `## ✍️ 创作要求\n\n`;
|
||||||
|
context += `1. **字数要求**:${targetWords}字左右(可根据情节需要适当调整)\n`;
|
||||||
|
context += `2. **内容要求**:严格按照章节细纲创作,不偏离主线\n`;
|
||||||
|
context += `3. **人物一致性**:确保人物性格、能力、说话方式等与核心设定一致\n`;
|
||||||
|
context += `4. **情节连贯**:与上一章自然衔接,确保情节发展合理\n`;
|
||||||
|
context += `5. **文笔要求**:描述生动,对话自然,节奏紧凑\n`;
|
||||||
|
|
||||||
|
if (currentChapter % 10 === 1) {
|
||||||
|
context += `6. **特殊提醒**:这是新一轮章节的开始,请特别注意巩固核心设定,防止情节偏离\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
context += `\n请开始创作第${currentChapter}章的完整内容:\n\n`;
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/utils/ollama.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { SystemConfig } from '../types';
|
||||||
|
|
||||||
|
export class OllamaService {
|
||||||
|
private config: SystemConfig;
|
||||||
|
|
||||||
|
constructor(config: SystemConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: SystemConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ollama连接测试失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModels(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.models?.map((model: any) => model.name) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模型列表失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableModelsWithInfo(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.models || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取模型详细信息失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(userMessage: string, context: string): Promise<string> {
|
||||||
|
const systemPrompt = `你是一位专业的写作助手和创意顾问,擅长帮助作者进行小说创作。你可以:
|
||||||
|
1. 讨论情节构思和发展
|
||||||
|
2. 分析人物性格和关系
|
||||||
|
3. 提供场景设置建议
|
||||||
|
4. 解决创作瓶颈
|
||||||
|
5. 提供写作技巧和指导
|
||||||
|
|
||||||
|
请根据用户的问题提供专业、有建设性的建议。`;
|
||||||
|
|
||||||
|
const prompt = `${context}
|
||||||
|
|
||||||
|
用户问题:${userMessage}
|
||||||
|
|
||||||
|
请提供专业建议:`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.config.ollamaUrl}/api/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.config.model,
|
||||||
|
prompt: prompt,
|
||||||
|
system: systemPrompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: this.config.temperature,
|
||||||
|
top_p: this.config.topP,
|
||||||
|
num_predict: this.config.maxTokens
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.response || '回复失败,请重试。';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ollama聊天错误:', error);
|
||||||
|
throw new Error('AI回复失败,请检查Ollama服务是否正常运行。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||