fix:初始化

This commit is contained in:
Your Name
2026-04-16 21:32:21 +08:00
parent 0b4772cac2
commit 235c98517d
51 changed files with 29453 additions and 0 deletions

238
README.md
View File

@@ -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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

1
build/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
build/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
build/manifest.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View 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*/

View 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":""}

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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
*/
/**![api](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkxNy43IDE0OC44bC00Mi40LTQyLjRjLTEuNi0xLjYtMy42LTIuMy01LjctMi4zcy00LjEuOC01LjcgMi4zbC03Ni4xIDc2LjFhMTk5LjI3IDE5OS4yNyAwIDAwLTExMi4xLTM0LjNjLTUxLjIgMC0xMDIuNCAxOS41LTE0MS41IDU4LjZMNDMyLjMgMzA4LjdhOC4wMyA4LjAzIDAgMDAwIDExLjNMNzA0IDU5MS43YzEuNiAxLjYgMy42IDIuMyA1LjcgMi4zIDIgMCA0LjEtLjggNS43LTIuM2wxMDEuOS0xMDEuOWM2OC45LTY5IDc3LTE3NS43IDI0LjMtMjUzLjVsNzYuMS03Ni4xYzMuMS0zLjIgMy4xLTguMyAwLTExLjR6TTc2OS4xIDQ0MS43bC01OS40IDU5LjQtMTg2LjgtMTg2LjggNTkuNC01OS40YzI0LjktMjQuOSA1OC4xLTM4LjcgOTMuNC0zOC43IDM1LjMgMCA2OC40IDEzLjcgOTMuNCAzOC43IDI0LjkgMjQuOSAzOC43IDU4LjEgMzguNyA5My40IDAgMzUuMy0xMy44IDY4LjQtMzguNyA5My40em0tMTkwLjIgMTA1YTguMDMgOC4wMyAwIDAwLTExLjMgMEw1MDEgNjEzLjMgNDEwLjcgNTIzbDY2LjctNjYuN2MzLjEtMy4xIDMuMS04LjIgMC0xMS4zTDQ0MSA0MDguNmE4LjAzIDguMDMgMCAwMC0xMS4zIDBMMzYzIDQ3NS4zbC00My00M2E3Ljg1IDcuODUgMCAwMC01LjctMi4zYy0yIDAtNC4xLjgtNS43IDIuM0wyMDYuOCA1MzQuMmMtNjguOSA2OS03NyAxNzUuNy0yNC4zIDI1My41bC03Ni4xIDc2LjFhOC4wMyA4LjAzIDAgMDAwIDExLjNsNDIuNCA0Mi40YzEuNiAxLjYgMy42IDIuMyA1LjcgMi4zczQuMS0uOCA1LjctMi4zbDc2LjEtNzYuMWMzMy43IDIyLjkgNzIuOSAzNC4zIDExMi4xIDM0LjMgNTEuMiAwIDEwMi40LTE5LjUgMTQxLjUtNTguNmwxMDEuOS0xMDEuOWMzLjEtMy4xIDMuMS04LjIgMC0xMS4zbC00My00MyA2Ni43LTY2LjdjMy4xLTMuMSAzLjEtOC4yIDAtMTEuM2wtMzYuNi0zNi4yek00NDEuNyA3NjkuMWExMzEuMzIgMTMxLjMyIDAgMDEtOTMuNCAzOC43Yy0zNS4zIDAtNjguNC0xMy43LTkzLjQtMzguN2ExMzEuMzIgMTMxLjMyIDAgMDEtMzguNy05My40YzAtMzUuMyAxMy43LTY4LjQgMzguNy05My40bDU5LjQtNTkuNCAxODYuOCAxODYuOC01OS40IDU5LjR6IiAvPjwvc3ZnPg==) */
/**![appstore](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQ2NCAxNDRIMTYwYy04LjggMC0xNiA3LjItMTYgMTZ2MzA0YzAgOC44IDcuMiAxNiAxNiAxNmgzMDRjOC44IDAgMTYtNy4yIDE2LTE2VjE2MGMwLTguOC03LjItMTYtMTYtMTZ6bS01MiAyNjhIMjEyVjIxMmgyMDB2MjAwem00NTItMjY4SDU2MGMtOC44IDAtMTYgNy4yLTE2IDE2djMwNGMwIDguOCA3LjIgMTYgMTYgMTZoMzA0YzguOCAwIDE2LTcuMiAxNi0xNlYxNjBjMC04LjgtNy4yLTE2LTE2LTE2em0tNTIgMjY4SDYxMlYyMTJoMjAwdjIwMHpNNDY0IDU0NEgxNjBjLTguOCAwLTE2IDcuMi0xNiAxNnYzMDRjMCA4LjggNy4yIDE2IDE2IDE2aDMwNGM4LjggMCAxNi03LjIgMTYtMTZWNTYwYzAtOC44LTcuMi0xNi0xNi0xNnptLTUyIDI2OEgyMTJWNjEyaDIwMHYyMDB6bTQ1Mi0yNjhINTYwYy04LjggMC0xNiA3LjItMTYgMTZ2MzA0YzAgOC44IDcuMiAxNiAxNiAxNmgzMDRjOC44IDAgMTYtNy4yIDE2LTE2VjU2MGMwLTguOC03LjItMTYtMTYtMTZ6bS01MiAyNjhINjEyVjYxMmgyMDB2MjAweiIgLz48L3N2Zz4=) */
/**![arrow-left](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg3MiA0NzRIMjg2LjlsMzUwLjItMzA0YzUuNi00LjkgMi4yLTE0LTUuMi0xNGgtODguNWMtMy45IDAtNy42IDEuNC0xMC41IDMuOUwxNTUgNDg3LjhhMzEuOTYgMzEuOTYgMCAwMDAgNDguM0w1MzUuMSA4NjZjMS41IDEuMyAzLjMgMiA1LjIgMmg5MS41YzcuNCAwIDEwLjgtOS4yIDUuMi0xNEwyODYuOSA1NTBIODcyYzQuNCAwIDgtMy42IDgtOHYtNjBjMC00LjQtMy42LTgtOC04eiIgLz48L3N2Zz4=) */
/**![bars](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkxMiAxOTJIMzI4Yy00LjQgMC04IDMuNi04IDh2NTZjMCA0LjQgMy42IDggOCA4aDU4NGM0LjQgMCA4LTMuNiA4LTh2LTU2YzAtNC40LTMuNi04LTgtOHptMCAyODRIMzI4Yy00LjQgMC04IDMuNi04IDh2NTZjMCA0LjQgMy42IDggOCA4aDU4NGM0LjQgMCA4LTMuNiA4LTh2LTU2YzAtNC40LTMuNi04LTgtOHptMCAyODRIMzI4Yy00LjQgMC04IDMuNi04IDh2NTZjMCA0LjQgMy42IDggOCA4aDU4NGM0LjQgMCA4LTMuNiA4LTh2LTU2YzAtNC40LTMuNi04LTgtOHpNMTA0IDIyOGE1NiA1NiAwIDEwMTEyIDAgNTYgNTYgMCAxMC0xMTIgMHptMCAyODRhNTYgNTYgMCAxMDExMiAwIDU2IDU2IDAgMTAtMTEyIDB6bTAgMjg0YTU2IDU2IDAgMTAxMTIgMCA1NiA1NiAwIDEwLTExMiAweiIgLz48L3N2Zz4=) */
/**![book](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgzMiA2NEgxOTJjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjgzMmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg2NDBjMTcuNyAwIDMyLTE0LjMgMzItMzJWOTZjMC0xNy43LTE0LjMtMzItMzItMzJ6bS0yNjAgNzJoOTZ2MjA5LjlMNjIxLjUgMzEyIDU3MiAzNDcuNFYxMzZ6bTIyMCA3NTJIMjMyVjEzNmgyODB2Mjk2LjljMCAzLjMgMSA2LjYgMyA5LjNhMTUuOSAxNS45IDAgMDAyMi4zIDMuN2w4My44LTU5LjkgODEuNCA1OS40YzIuNyAyIDYgMy4xIDkuNCAzLjEgOC44IDAgMTYtNy4yIDE2LTE2VjEzNmg2NHY3NTJ6IiAvPjwvc3ZnPg==) */
/**![bulb](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYzMiA4ODhIMzkyYy00LjQgMC04IDMuNi04IDh2MzJjMCAxNy43IDE0LjMgMzIgMzIgMzJoMTkyYzE3LjcgMCAzMi0xNC4zIDMyLTMydi0zMmMwLTQuNC0zLjYtOC04LTh6TTUxMiA2NGMtMTgxLjEgMC0zMjggMTQ2LjktMzI4IDMyOCAwIDEyMS40IDY2IDIyNy40IDE2NCAyODQuMVY3OTJjMCAxNy43IDE0LjMgMzIgMzIgMzJoMjY0YzE3LjcgMCAzMi0xNC4zIDMyLTMyVjY3Ni4xYzk4LTU2LjcgMTY0LTE2Mi43IDE2NC0yODQuMSAwLTE4MS4xLTE0Ni45LTMyOC0zMjgtMzI4em0xMjcuOSA1NDkuOEw2MDQgNjM0LjZWNzUySDQyMFY2MzQuNmwtMzUuOS0yMC44QzMwNS40IDU2OC4zIDI1NiA0ODQuNSAyNTYgMzkyYzAtMTQxLjQgMTE0LjYtMjU2IDI1Ni0yNTZzMjU2IDExNC42IDI1NiAyNTZjMCA5Mi41LTQ5LjQgMTc2LjMtMTI4LjEgMjIxLjh6IiAvPjwvc3ZnPg==) */
/**![check-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEMyNjQuNiA2NCA2NCAyNjQuNiA2NCA1MTJzMjAwLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMC42IDQ0OC00NDhTNzU5LjQgNjQgNTEyIDY0em0xOTMuNSAzMDEuN2wtMjEwLjYgMjkyYTMxLjggMzEuOCAwIDAxLTUxLjcgMEwzMTguNSA0ODQuOWMtMy44LTUuMyAwLTEyLjcgNi41LTEyLjdoNDYuOWMxMC4yIDAgMTkuOSA0LjkgMjUuOSAxMy4zbDcxLjIgOTguOCAxNTcuMi0yMThjNi04LjMgMTUuNi0xMy4zIDI1LjktMTMuM0g2OTljNi41IDAgMTAuMyA3LjQgNi41IDEyLjd6IiAvPjwvc3ZnPg==) */
/**![check-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTY5OSAzNTNoLTQ2LjljLTEwLjIgMC0xOS45IDQuOS0yNS45IDEzLjNMNDY5IDU4NC4zbC03MS4yLTk4LjhjLTYtOC4zLTE1LjYtMTMuMy0yNS45LTEzLjNIMzI1Yy02LjUgMC0xMC4zIDcuNC02LjUgMTIuN2wxMjQuNiAxNzIuOGEzMS44IDMxLjggMCAwMDUxLjcgMGwyMTAuNi0yOTJjMy45LTUuMy4xLTEyLjctNi40LTEyLjd6IiAvPjxwYXRoIGQ9Ik01MTIgNjRDMjY0LjYgNjQgNjQgMjY0LjYgNjQgNTEyczIwMC42IDQ0OCA0NDggNDQ4IDQ0OC0yMDAuNiA0NDgtNDQ4Uzc1OS40IDY0IDUxMiA2NHptMCA4MjBjLTIwNS40IDAtMzcyLTE2Ni42LTM3Mi0zNzJzMTY2LjYtMzcyIDM3Mi0zNzIgMzcyIDE2Ni42IDM3MiAzNzItMTY2LjYgMzcyLTM3MiAzNzJ6IiAvPjwvc3ZnPg==) */
/**![check](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkxMiAxOTBoLTY5LjljLTkuOCAwLTE5LjEgNC41LTI1LjEgMTIuMkw0MDQuNyA3MjQuNSAyMDcgNDc0YTMyIDMyIDAgMDAtMjUuMS0xMi4ySDExMmMtNi43IDAtMTAuNCA3LjctNi4zIDEyLjlsMjczLjkgMzQ3YzEyLjggMTYuMiAzNy40IDE2LjIgNTAuMyAwbDQ4OC40LTYxOC45YzQuMS01LjEuNC0xMi44LTYuMy0xMi44eiIgLz48L3N2Zz4=) */
/**![clear](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHN0eWxlIC8+PC9kZWZzPjxwYXRoIGQ9Ik04OTkuMSA4NjkuNmwtNTMtMzA1LjZIODY0YzE0LjQgMCAyNi0xMS42IDI2LTI2VjM0NmMwLTE0LjQtMTEuNi0yNi0yNi0yNkg2MThWMTM4YzAtMTQuNC0xMS42LTI2LTI2LTI2SDQzMmMtMTQuNCAwLTI2IDExLjYtMjYgMjZ2MTgySDE2MGMtMTQuNCAwLTI2IDExLjYtMjYgMjZ2MTkyYzAgMTQuNCAxMS42IDI2IDI2IDI2aDE3LjlsLTUzIDMwNS42YTI1Ljk1IDI1Ljk1IDAgMDAyNS42IDMwLjRoNzIzYzEuNSAwIDMtLjEgNC40LS40YTI1Ljg4IDI1Ljg4IDAgMDAyMS4yLTMwek0yMDQgMzkwaDI3MlYxODJoNzJ2MjA4aDI3MnYxMDRIMjA0VjM5MHptNDY4IDQ0MFY2NzRjMC00LjQtMy42LTgtOC04aC00OGMtNC40IDAtOCAzLjYtOCA4djE1Nkg0MTZWNjc0YzAtNC40LTMuNi04LTgtOGgtNDhjLTQuNCAwLTggMy42LTggOHYxNTZIMjAyLjhsNDUuMS0yNjBINzc2bDQ1LjEgMjYwSDY3MnoiIC8+PC9zdmc+) */
/**![close-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIGZpbGwtcnVsZT0iZXZlbm9kZCIgdmlld0JveD0iNjQgNjQgODk2IDg5NiIgZm9jdXNhYmxlPSJmYWxzZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNTEyIDY0YzI0Ny40IDAgNDQ4IDIwMC42IDQ0OCA0NDhTNzU5LjQgOTYwIDUxMiA5NjAgNjQgNzU5LjQgNjQgNTEyIDI2NC42IDY0IDUxMiA2NHptMTI3Ljk4IDI3NC44MmgtLjA0bC0uMDguMDZMNTEyIDQ2Ni43NSAzODQuMTQgMzM4Ljg4Yy0uMDQtLjA1LS4wNi0uMDYtLjA4LS4wNmEuMTIuMTIgMCAwMC0uMDcgMGMtLjAzIDAtLjA1LjAxLS4wOS4wNWwtNDUuMDIgNDUuMDJhLjIuMiAwIDAwLS4wNS4wOS4xMi4xMiAwIDAwMCAuMDd2LjAyYS4yNy4yNyAwIDAwLjA2LjA2TDQ2Ni43NSA1MTIgMzM4Ljg4IDYzOS44NmMtLjA1LjA0LS4wNi4wNi0uMDYuMDhhLjEyLjEyIDAgMDAwIC4wN2MwIC4wMy4wMS4wNS4wNS4wOWw0NS4wMiA0NS4wMmEuMi4yIDAgMDAuMDkuMDUuMTIuMTIgMCAwMC4wNyAwYy4wMiAwIC4wNC0uMDEuMDgtLjA1TDUxMiA1NTcuMjVsMTI3Ljg2IDEyNy44N2MuMDQuMDQuMDYuMDUuMDguMDVhLjEyLjEyIDAgMDAuMDcgMGMuMDMgMCAuMDUtLjAxLjA5LS4wNWw0NS4wMi00NS4wMmEuMi4yIDAgMDAuMDUtLjA5LjEyLjEyIDAgMDAwLS4wN3YtLjAyYS4yNy4yNyAwIDAwLS4wNS0uMDZMNTU3LjI1IDUxMmwxMjcuODctMTI3Ljg2Yy4wNC0uMDQuMDUtLjA2LjA1LS4wOGEuMTIuMTIgMCAwMDAtLjA3YzAtLjAzLS4wMS0uMDUtLjA1LS4wOWwtNDUuMDItNDUuMDJhLjIuMiAwIDAwLS4wOS0uMDUuMTIuMTIgMCAwMC0uMDcgMHoiIC8+PC9zdmc+) */
/**![close](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIGZpbGwtcnVsZT0iZXZlbm9kZCIgdmlld0JveD0iNjQgNjQgODk2IDg5NiIgZm9jdXNhYmxlPSJmYWxzZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNzk5Ljg2IDE2Ni4zMWMuMDIgMCAuMDQuMDIuMDguMDZsNTcuNjkgNTcuN2MuMDQuMDMuMDUuMDUuMDYuMDhhLjEyLjEyIDAgMDEwIC4wNmMwIC4wMy0uMDIuMDUtLjA2LjA5TDU2OS45MyA1MTJsMjg3LjcgMjg3LjdjLjA0LjA0LjA1LjA2LjA2LjA5YS4xMi4xMiAwIDAxMCAuMDdjMCAuMDItLjAyLjA0LS4wNi4wOGwtNTcuNyA1Ny42OWMtLjAzLjA0LS4wNS4wNS0uMDcuMDZhLjEyLjEyIDAgMDEtLjA3IDBjLS4wMyAwLS4wNS0uMDItLjA5LS4wNkw1MTIgNTY5LjkzbC0yODcuNyAyODcuN2MtLjA0LjA0LS4wNi4wNS0uMDkuMDZhLjEyLjEyIDAgMDEtLjA3IDBjLS4wMiAwLS4wNC0uMDItLjA4LS4wNmwtNTcuNjktNTcuN2MtLjA0LS4wMy0uMDUtLjA1LS4wNi0uMDdhLjEyLjEyIDAgMDEwLS4wN2MwLS4wMy4wMi0uMDUuMDYtLjA5TDQ1NC4wNyA1MTJsLTI4Ny43LTI4Ny43Yy0uMDQtLjA0LS4wNS0uMDYtLjA2LS4wOWEuMTIuMTIgMCAwMTAtLjA3YzAtLjAyLjAyLS4wNC4wNi0uMDhsNTcuNy01Ny42OWMuMDMtLjA0LjA1LS4wNS4wNy0uMDZhLjEyLjEyIDAgMDEuMDcgMGMuMDMgMCAuMDUuMDIuMDkuMDZMNTEyIDQ1NC4wN2wyODcuNy0yODcuN2MuMDQtLjA0LjA2LS4wNS4wOS0uMDZhLjEyLjEyIDAgMDEuMDcgMHoiIC8+PC9zdmc+) */
/**![cloud-upload](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxOC4zIDQ1OWE4IDggMCAwMC0xMi42IDBsLTExMiAxNDEuN2E3Ljk4IDcuOTggMCAwMDYuMyAxMi45aDczLjlWODU2YzAgNC40IDMuNiA4IDggOGg2MGM0LjQgMCA4LTMuNiA4LThWNjEzLjdINjI0YzYuNyAwIDEwLjQtNy43IDYuMy0xMi45TDUxOC4zIDQ1OXoiIC8+PHBhdGggZD0iTTgxMS40IDM2Ni43Qzc2NS42IDI0NS45IDY0OC45IDE2MCA1MTIuMiAxNjBTMjU4LjggMjQ1LjggMjEzIDM2Ni42QzEyNy4zIDM4OS4xIDY0IDQ2Ny4yIDY0IDU2MGMwIDExMC41IDg5LjUgMjAwIDE5OS45IDIwMEgzMDRjNC40IDAgOC0zLjYgOC04di02MGMwLTQuNC0zLjYtOC04LThoLTQwLjFjLTMzLjcgMC02NS40LTEzLjQtODktMzcuNy0yMy41LTI0LjItMzYtNTYuOC0zNC45LTkwLjYuOS0yNi40IDkuOS01MS4yIDI2LjItNzIuMSAxNi43LTIxLjMgNDAuMS0zNi44IDY2LjEtNDMuN2wzNy45LTkuOSAxMy45LTM2LjZjOC42LTIyLjggMjAuNi00NC4xIDM1LjctNjMuNGEyNDUuNiAyNDUuNiAwIDAxNTIuNC00OS45YzQxLjEtMjguOSA4OS41LTQ0LjIgMTQwLTQ0LjJzOTguOSAxNS4zIDE0MCA0NC4yYzE5LjkgMTQgMzcuNSAzMC44IDUyLjQgNDkuOSAxNS4xIDE5LjMgMjcuMSA0MC43IDM1LjcgNjMuNGwxMy44IDM2LjUgMzcuOCAxMEM4NDYuMSA0NTQuNSA4ODQgNTAzLjggODg0IDU2MGMwIDMzLjEtMTIuOSA2NC4zLTM2LjMgODcuN2ExMjMuMDcgMTIzLjA3IDAgMDEtODcuNiAzNi4zSDcyMGMtNC40IDAtOCAzLjYtOCA4djYwYzAgNC40IDMuNiA4IDggOGg0MC4xQzg3MC41IDc2MCA5NjAgNjcwLjUgOTYwIDU2MGMwLTkyLjctNjMuMS0xNzAuNy0xNDguNi0xOTMuM3oiIC8+PC9zdmc+) */
/**![code](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxNiA2NzNjMCA0LjQgMy40IDggNy41IDhoMTg1YzQuMSAwIDcuNS0zLjYgNy41LTh2LTQ4YzAtNC40LTMuNC04LTcuNS04aC0xODVjLTQuMSAwLTcuNSAzLjYtNy41IDh2NDh6bS0xOTQuOSA2LjFsMTkyLTE2MWMzLjgtMy4yIDMuOC05LjEgMC0xMi4zbC0xOTItMTYwLjlBNy45NSA3Ljk1IDAgMDAzMDggMzUxdjYyLjdjMCAyLjQgMSA0LjYgMi45IDYuMUw0MjAuNyA1MTJsLTEwOS44IDkyLjJhOC4xIDguMSAwIDAwLTIuOSA2LjFWNjczYzAgNi44IDcuOSAxMC41IDEzLjEgNi4xek04ODAgMTEySDE0NGMtMTcuNyAwLTMyIDE0LjMtMzIgMzJ2NzM2YzAgMTcuNyAxNC4zIDMyIDMyIDMyaDczNmMxNy43IDAgMzItMTQuMyAzMi0zMlYxNDRjMC0xNy43LTE0LjMtMzItMzItMzJ6bS00MCA3MjhIMTg0VjE4NGg2NTZ2NjU2eiIgLz48L3N2Zz4=) */
/**![copy](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgzMiA2NEgyOTZjLTQuNCAwLTggMy42LTggOHY1NmMwIDQuNCAzLjYgOCA4IDhoNDk2djY4OGMwIDQuNCAzLjYgOCA4IDhoNTZjNC40IDAgOC0zLjYgOC04Vjk2YzAtMTcuNy0xNC4zLTMyLTMyLTMyek03MDQgMTkySDE5MmMtMTcuNyAwLTMyIDE0LjMtMzIgMzJ2NTMwLjdjMCA4LjUgMy40IDE2LjYgOS40IDIyLjZsMTczLjMgMTczLjNjMi4yIDIuMiA0LjcgNCA3LjQgNS41djEuOWg0LjJjMy41IDEuMyA3LjIgMiAxMSAySDcwNGMxNy43IDAgMzItMTQuMyAzMi0zMlYyMjRjMC0xNy43LTE0LjMtMzItMzItMzJ6TTM1MCA4NTYuMkwyNjMuOSA3NzBIMzUwdjg2LjJ6TTY2NCA4ODhINDE0Vjc0NmMwLTIyLjEtMTcuOS00MC00MC00MEgyMzJWMjY0aDQzMnY2MjR6IiAvPjwvc3ZnPg==) */
/**![database](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgzMiA2NEgxOTJjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjgzMmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg2NDBjMTcuNyAwIDMyLTE0LjMgMzItMzJWOTZjMC0xNy43LTE0LjMtMzItMzItMzJ6bS02MDAgNzJoNTYwdjIwOEgyMzJWMTM2em01NjAgNDgwSDIzMlY0MDhoNTYwdjIwOHptMCAyNzJIMjMyVjY4MGg1NjB2MjA4ek0zMDQgMjQwYTQwIDQwIDAgMTA4MCAwIDQwIDQwIDAgMTAtODAgMHptMCAyNzJhNDAgNDAgMCAxMDgwIDAgNDAgNDAgMCAxMC04MCAwem0wIDI3MmE0MCA0MCAwIDEwODAgMCA0MCA0MCAwIDEwLTgwIDB6IiAvPjwvc3ZnPg==) */
/**![delete](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTM2MCAxODRoLThjNC40IDAgOC0zLjYgOC04djhoMzA0di04YzAgNC40IDMuNiA4IDggOGgtOHY3Mmg3MnYtODBjMC0zNS4zLTI4LjctNjQtNjQtNjRIMzUyYy0zNS4zIDAtNjQgMjguNy02NCA2NHY4MGg3MnYtNzJ6bTUwNCA3MkgxNjBjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjMyYzAgNC40IDMuNiA4IDggOGg2MC40bDI0LjcgNTIzYzEuNiAzNC4xIDI5LjggNjEgNjMuOSA2MWg0NTRjMzQuMiAwIDYyLjMtMjYuOCA2My45LTYxbDI0LjctNTIzSDg4OGM0LjQgMCA4LTMuNiA4LTh2LTMyYzAtMTcuNy0xNC4zLTMyLTMyLTMyek03MzEuMyA4NDBIMjkyLjdsLTI0LjItNTEyaDQ4N2wtMjQuMiA1MTJ6IiAvPjwvc3ZnPg==) */
/**![double-left](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTI3Mi45IDUxMmwyNjUuNC0zMzkuMWM0LjEtNS4yLjQtMTIuOS02LjMtMTIuOWgtNzcuM2MtNC45IDAtOS42IDIuMy0xMi42IDYuMUwxODYuOCA0OTIuM2EzMS45OSAzMS45OSAwIDAwMCAzOS41bDI1NS4zIDMyNi4xYzMgMy45IDcuNyA2LjEgMTIuNiA2LjFINTMyYzYuNyAwIDEwLjQtNy43IDYuMy0xMi45TDI3Mi45IDUxMnptMzA0IDBsMjY1LjQtMzM5LjFjNC4xLTUuMi40LTEyLjktNi4zLTEyLjloLTc3LjNjLTQuOSAwLTkuNiAyLjMtMTIuNiA2LjFMNDkwLjggNDkyLjNhMzEuOTkgMzEuOTkgMCAwMDAgMzkuNWwyNTUuMyAzMjYuMWMzIDMuOSA3LjcgNi4xIDEyLjYgNi4xSDgzNmM2LjcgMCAxMC40LTcuNyA2LjMtMTIuOUw1NzYuOSA1MTJ6IiAvPjwvc3ZnPg==) */
/**![double-right](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUzMy4yIDQ5Mi4zTDI3Ny45IDE2Ni4xYy0zLTMuOS03LjctNi4xLTEyLjYtNi4xSDE4OGMtNi43IDAtMTAuNCA3LjctNi4zIDEyLjlMNDQ3LjEgNTEyIDE4MS43IDg1MS4xQTcuOTggNy45OCAwIDAwMTg4IDg2NGg3Ny4zYzQuOSAwIDkuNi0yLjMgMTIuNi02LjFsMjU1LjMtMzI2LjFjOS4xLTExLjcgOS4xLTI3LjkgMC0zOS41em0zMDQgMEw1ODEuOSAxNjYuMWMtMy0zLjktNy43LTYuMS0xMi42LTYuMUg0OTJjLTYuNyAwLTEwLjQgNy43LTYuMyAxMi45TDc1MS4xIDUxMiA0ODUuNyA4NTEuMUE3Ljk4IDcuOTggMCAwMDQ5MiA4NjRoNzcuM2M0LjkgMCA5LjYtMi4zIDEyLjYtNi4xbDI1NS4zLTMyNi4xYzkuMS0xMS43IDkuMS0yNy45IDAtMzkuNXoiIC8+PC9zdmc+) */
/**![down](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg4NCAyNTZoLTc1Yy01LjEgMC05LjkgMi41LTEyLjkgNi42TDUxMiA2NTQuMiAyMjcuOSAyNjIuNmMtMy00LjEtNy44LTYuNi0xMi45LTYuNmgtNzVjLTYuNSAwLTEwLjMgNy40LTYuNSAxMi43bDM1Mi42IDQ4Ni4xYzEyLjggMTcuNiAzOSAxNy42IDUxLjcgMGwzNTIuNi00ODYuMWMzLjktNS4zLjEtMTIuNy02LjQtMTIuN3oiIC8+PC9zdmc+) */
/**![download](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUwNS43IDY2MWE4IDggMCAwMDEyLjYgMGwxMTItMTQxLjdjNC4xLTUuMi40LTEyLjktNi4zLTEyLjloLTc0LjFWMTY4YzAtNC40LTMuNi04LTgtOGgtNjBjLTQuNCAwLTggMy42LTggOHYzMzguM0g0MDBjLTYuNyAwLTEwLjQgNy43LTYuMyAxMi45bDExMiAxNDEuOHpNODc4IDYyNmgtNjBjLTQuNCAwLTggMy42LTggOHYxNTRIMjE0VjYzNGMwLTQuNC0zLjYtOC04LThoLTYwYy00LjQgMC04IDMuNi04IDh2MTk4YzAgMTcuNyAxNC4zIDMyIDMyIDMyaDY4NGMxNy43IDAgMzItMTQuMyAzMi0zMlY2MzRjMC00LjQtMy42LTgtOC04eiIgLz48L3N2Zz4=) */
/**![edit](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTI1Ny43IDc1MmMyIDAgNC0uMiA2LS41TDQzMS45IDcyMmMyLS40IDMuOS0xLjMgNS4zLTIuOGw0MjMuOS00MjMuOWE5Ljk2IDkuOTYgMCAwMDAtMTQuMUw2OTQuOSAxMTQuOWMtMS45LTEuOS00LjQtMi45LTcuMS0yLjlzLTUuMiAxLTcuMSAyLjlMMjU2LjggNTM4LjhjLTEuNSAxLjUtMi40IDMuMy0yLjggNS4zbC0yOS41IDE2OC4yYTMzLjUgMzMuNSAwIDAwOS40IDI5LjhjNi42IDYuNCAxNC45IDkuOSAyMy44IDkuOXptNjcuNC0xNzQuNEw2ODcuOCAyMTVsNzMuMyA3My4zLTM2Mi43IDM2Mi42LTg4LjkgMTUuNyAxNS42LTg5ek04ODAgODM2SDE0NGMtMTcuNyAwLTMyIDE0LjMtMzIgMzJ2MzZjMCA0LjQgMy42IDggOCA4aDc4NGM0LjQgMCA4LTMuNiA4LTh2LTM2YzAtMTcuNy0xNC4zLTMyLTMyLTMyeiIgLz48L3N2Zz4=) */
/**![ellipsis](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE3NiA1MTFhNTYgNTYgMCAxMDExMiAwIDU2IDU2IDAgMTAtMTEyIDB6bTI4MCAwYTU2IDU2IDAgMTAxMTIgMCA1NiA1NiAwIDEwLTExMiAwem0yODAgMGE1NiA1NiAwIDEwMTEyIDAgNTYgNTYgMCAxMC0xMTIgMHoiIC8+PC9zdmc+) */
/**![enter](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg2NCAxNzBoLTYwYy00LjQgMC04IDMuNi04IDh2NTE4SDMxMHYtNzNjMC02LjctNy44LTEwLjUtMTMtNi4zbC0xNDEuOSAxMTJhOCA4IDAgMDAwIDEyLjZsMTQxLjkgMTEyYzUuMyA0LjIgMTMgLjQgMTMtNi4zdi03NWg0OThjMzUuMyAwIDY0LTI4LjcgNjQtNjRWMTc4YzAtNC40LTMuNi04LTgtOHoiIC8+PC9zdmc+) */
/**![exclamation-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEMyNjQuNiA2NCA2NCAyNjQuNiA2NCA1MTJzMjAwLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMC42IDQ0OC00NDhTNzU5LjQgNjQgNTEyIDY0em0tMzIgMjMyYzAtNC40IDMuNi04IDgtOGg0OGM0LjQgMCA4IDMuNiA4IDh2MjcyYzAgNC40LTMuNiA4LTggOGgtNDhjLTQuNCAwLTgtMy42LTgtOFYyOTZ6bTMyIDQ0MGE0OC4wMSA0OC4wMSAwIDAxMC05NiA0OC4wMSA0OC4wMSAwIDAxMCA5NnoiIC8+PC9zdmc+) */
/**![exclamation-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEMyNjQuNiA2NCA2NCAyNjQuNiA2NCA1MTJzMjAwLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMC42IDQ0OC00NDhTNzU5LjQgNjQgNTEyIDY0em0wIDgyMGMtMjA1LjQgMC0zNzItMTY2LjYtMzcyLTM3MnMxNjYuNi0zNzIgMzcyLTM3MiAzNzIgMTY2LjYgMzcyIDM3Mi0xNjYuNiAzNzItMzcyIDM3MnoiIC8+PHBhdGggZD0iTTQ2NCA2ODhhNDggNDggMCAxMDk2IDAgNDggNDggMCAxMC05NiAwem0yNC0xMTJoNDhjNC40IDAgOC0zLjYgOC04VjI5NmMwLTQuNC0zLjYtOC04LThoLTQ4Yy00LjQgMC04IDMuNi04IDh2MjcyYzAgNC40IDMuNiA4IDggOHoiIC8+PC9zdmc+) */
/**![eye-invisible](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0Mi4yIDQ4Ni4yUTg4OS40NyAzNzUuMTEgODE2LjcgMzA1bC01MC44OCA1MC44OEM4MDcuMzEgMzk1LjUzIDg0My40NSA0NDcuNCA4NzQuNyA1MTIgNzkxLjUgNjg0LjIgNjczLjQgNzY2IDUxMiA3NjZxLTcyLjY3IDAtMTMzLjg3LTIyLjM4TDMyMyA3OTguNzVRNDA4IDgzOCA1MTIgODM4cTI4OC4zIDAgNDMwLjItMzAwLjNhNjAuMjkgNjAuMjkgMCAwMDAtNTEuNXptLTYzLjU3LTMyMC42NEw4MzYgMTIyLjg4YTggOCAwIDAwLTExLjMyIDBMNzE1LjMxIDIzMi4yUTYyNC44NiAxODYgNTEyIDE4NnEtMjg4LjMgMC00MzAuMiAzMDAuM2E2MC4zIDYwLjMgMCAwMDAgNTEuNXE1Ni42OSAxMTkuNCAxMzYuNSAxOTEuNDFMMTEyLjQ4IDgzNWE4IDggMCAwMDAgMTEuMzFMMTU1LjE3IDg4OWE4IDggMCAwMDExLjMxIDBsNzEyLjE1LTcxMi4xMmE4IDggMCAwMDAtMTEuMzJ6TTE0OS4zIDUxMkMyMzIuNiAzMzkuOCAzNTAuNyAyNTggNTEyIDI1OGM1NC41NCAwIDEwNC4xMyA5LjM2IDE0OS4xMiAyOC4zOWwtNzAuMyA3MC4zYTE3NiAxNzYgMCAwMC0yMzguMTMgMjM4LjEzbC04My40MiA4My40MkMyMjMuMSA2MzcuNDkgMTgzLjMgNTgyLjI4IDE0OS4zIDUxMnptMjQ2LjcgMGExMTIuMTEgMTEyLjExIDAgMDExNDYuMi0xMDYuNjlMNDAxLjMxIDU0Ni4yQTExMiAxMTIgMCAwMTM5NiA1MTJ6IiAvPjxwYXRoIGQ9Ik01MDggNjI0Yy0zLjQ2IDAtNi44Ny0uMTYtMTAuMjUtLjQ3bC01Mi44MiA1Mi44MmExNzYuMDkgMTc2LjA5IDAgMDAyMjcuNDItMjI3LjQybC01Mi44MiA1Mi44MmMuMzEgMy4zOC40NyA2Ljc5LjQ3IDEwLjI1YTExMS45NCAxMTEuOTQgMCAwMS0xMTIgMTEyeiIgLz48L3N2Zz4=) */
/**![eye](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk0Mi4yIDQ4Ni4yQzg0Ny40IDI4Ni41IDcwNC4xIDE4NiA1MTIgMTg2Yy0xOTIuMiAwLTMzNS40IDEwMC41LTQzMC4yIDMwMC4zYTYwLjMgNjAuMyAwIDAwMCA1MS41QzE3Ni42IDczNy41IDMxOS45IDgzOCA1MTIgODM4YzE5Mi4yIDAgMzM1LjQtMTAwLjUgNDMwLjItMzAwLjMgNy43LTE2LjIgNy43LTM1IDAtNTEuNXpNNTEyIDc2NmMtMTYxLjMgMC0yNzkuNC04MS44LTM2Mi43LTI1NEMyMzIuNiAzMzkuOCAzNTAuNyAyNTggNTEyIDI1OGMxNjEuMyAwIDI3OS40IDgxLjggMzYyLjcgMjU0Qzc5MS41IDY4NC4yIDY3My40IDc2NiA1MTIgNzY2em0tNC00MzBjLTk3LjIgMC0xNzYgNzguOC0xNzYgMTc2czc4LjggMTc2IDE3NiAxNzYgMTc2LTc4LjggMTc2LTE3Ni03OC44LTE3Ni0xNzYtMTc2em0wIDI4OGMtNjEuOSAwLTExMi01MC4xLTExMi0xMTJzNTAuMS0xMTIgMTEyLTExMiAxMTIgNTAuMSAxMTIgMTEyLTUwLjEgMTEyLTExMiAxMTJ6IiAvPjwvc3ZnPg==) */
/**![fund](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkyNiAxNjRIOTRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjY0MGMwIDE3LjcgMTQuMyAzMiAzMiAzMmg4MzJjMTcuNyAwIDMyLTE0LjMgMzItMzJWMTk2YzAtMTcuNy0xNC4zLTMyLTMyLTMyem0tNDAgNjMySDEzNFYyMzZoNzUydjU2MHptLTY1OC45LTgyLjNjMy4xIDMuMSA4LjIgMy4xIDExLjMgMGwxNzIuNS0xNzIuNSAxMTQuNCAxMTQuNWMzLjEgMy4xIDguMiAzLjEgMTEuMyAwbDI5Ny0yOTcuMmMzLjEtMy4xIDMuMS04LjIgMC0xMS4zbC0zNi44LTM2LjhhOC4wMyA4LjAzIDAgMDAtMTEuMyAwTDUzMSA1NjUgNDE2LjYgNDUwLjVhOC4wMyA4LjAzIDAgMDAtMTEuMyAwbC0yMTQuOSAyMTVhOC4wMyA4LjAzIDAgMDAwIDExLjNsMzYuNyAzNi45eiIgLz48L3N2Zz4=) */
/**![info-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEMyNjQuNiA2NCA2NCAyNjQuNiA2NCA1MTJzMjAwLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMC42IDQ0OC00NDhTNzU5LjQgNjQgNTEyIDY0em0zMiA2NjRjMCA0LjQtMy42IDgtOCA4aC00OGMtNC40IDAtOC0zLjYtOC04VjQ1NmMwLTQuNCAzLjYtOCA4LThoNDhjNC40IDAgOCAzLjYgOCA4djI3MnptLTMyLTM0NGE0OC4wMSA0OC4wMSAwIDAxMC05NiA0OC4wMSA0OC4wMSAwIDAxMCA5NnoiIC8+PC9zdmc+) */
/**![left](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcyNCAyMTguM1YxNDFjMC02LjctNy43LTEwLjQtMTIuOS02LjNMMjYwLjMgNDg2LjhhMzEuODYgMzEuODYgMCAwMDAgNTAuM2w0NTAuOCAzNTIuMWM1LjMgNC4xIDEyLjkuNCAxMi45LTYuM3YtNzcuM2MwLTQuOS0yLjMtOS42LTYuMS0xMi42bC0zNjAtMjgxIDM2MC0yODEuMWMzLjgtMyA2LjEtNy43IDYuMS0xMi42eiIgLz48L3N2Zz4=) */
/**![loading](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTk4OCA1NDhjLTE5LjkgMC0zNi0xNi4xLTM2LTM2IDAtNTkuNC0xMS42LTExNy0zNC42LTE3MS4zYTQ0MC40NSA0NDAuNDUgMCAwMC05NC4zLTEzOS45IDQzNy43MSA0MzcuNzEgMCAwMC0xMzkuOS05NC4zQzYyOSA4My42IDU3MS40IDcyIDUxMiA3MmMtMTkuOSAwLTM2LTE2LjEtMzYtMzZzMTYuMS0zNiAzNi0zNmM2OS4xIDAgMTM2LjIgMTMuNSAxOTkuMyA0MC4zQzc3Mi4zIDY2IDgyNyAxMDMgODc0IDE1MGM0NyA0NyA4My45IDEwMS44IDEwOS43IDE2Mi43IDI2LjcgNjMuMSA0MC4yIDEzMC4yIDQwLjIgMTk5LjMuMSAxOS45LTE2IDM2LTM1LjkgMzZ6IiAvPjwvc3ZnPg==) */
/**![message](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQ2NCA1MTJhNDggNDggMCAxMDk2IDAgNDggNDggMCAxMC05NiAwem0yMDAgMGE0OCA0OCAwIDEwOTYgMCA0OCA0OCAwIDEwLTk2IDB6bS00MDAgMGE0OCA0OCAwIDEwOTYgMCA0OCA0OCAwIDEwLTk2IDB6bTY2MS4yLTE3My42Yy0yMi42LTUzLjctNTUtMTAxLjktOTYuMy0xNDMuM2E0NDQuMzUgNDQ0LjM1IDAgMDAtMTQzLjMtOTYuM0M2MzAuNiA3NS43IDU3Mi4yIDY0IDUxMiA2NGgtMmMtNjAuNi4zLTExOS4zIDEyLjMtMTc0LjUgMzUuOWE0NDUuMzUgNDQ1LjM1IDAgMDAtMTQyIDk2LjVjLTQwLjkgNDEuMy03MyA4OS4zLTk1LjIgMTQyLjgtMjMgNTUuNC0zNC42IDExNC4zLTM0LjMgMTc0LjlBNDQ5LjQgNDQ5LjQgMCAwMDExMiA3MTR2MTUyYTQ2IDQ2IDAgMDA0NiA0NmgxNTIuMUE0NDkuNCA0NDkuNCAwIDAwNTEwIDk2MGgyLjFjNTkuOSAwIDExOC0xMS42IDE3Mi43LTM0LjNhNDQ0LjQ4IDQ0NC40OCAwIDAwMTQyLjgtOTUuMmM0MS4zLTQwLjkgNzMuOC04OC43IDk2LjUtMTQyIDIzLjYtNTUuMiAzNS42LTExMy45IDM1LjktMTc0LjUuMy02MC45LTExLjUtMTIwLTM0LjgtMTc1LjZ6bS0xNTEuMSA0MzhDNzA0IDg0NS44IDYxMSA4ODQgNTEyIDg4NGgtMS43Yy02MC4zLS4zLTEyMC4yLTE1LjMtMTczLjEtNDMuNWwtOC40LTQuNUgxODhWNjk1LjJsLTQuNS04LjRDMTU1LjMgNjMzLjkgMTQwLjMgNTc0IDE0MCA1MTMuN2MtLjQtOTkuNyAzNy43LTE5My4zIDEwNy42LTI2My44IDY5LjgtNzAuNSAxNjMuMS0xMDkuNSAyNjIuOC0xMDkuOWgxLjdjNTAgMCA5OC41IDkuNyAxNDQuMiAyOC45IDQ0LjYgMTguNyA4NC42IDQ1LjYgMTE5IDgwIDM0LjMgMzQuMyA2MS4zIDc0LjQgODAgMTE5IDE5LjQgNDYuMiAyOS4xIDk1LjIgMjguOSAxNDUuOC0uNiA5OS42LTM5LjcgMTkyLjktMTEwLjEgMjYyLjd6IiAvPjwvc3ZnPg==) */
/**![plus-square](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMyOCA1NDRoMTUydjE1MmMwIDQuNCAzLjYgOCA4IDhoNDhjNC40IDAgOC0zLjYgOC04VjU0NGgxNTJjNC40IDAgOC0zLjYgOC04di00OGMwLTQuNC0zLjYtOC04LThINTQ0VjMyOGMwLTQuNC0zLjYtOC04LThoLTQ4Yy00LjQgMC04IDMuNi04IDh2MTUySDMyOGMtNC40IDAtOCAzLjYtOCA4djQ4YzAgNC40IDMuNiA4IDggOHoiIC8+PHBhdGggZD0iTTg4MCAxMTJIMTQ0Yy0xNy43IDAtMzIgMTQuMy0zMiAzMnY3MzZjMCAxNy43IDE0LjMgMzIgMzIgMzJoNzM2YzE3LjcgMCAzMi0xNC4zIDMyLTMyVjE0NGMwLTE3LjctMTQuMy0zMi0zMi0zMnptLTQwIDcyOEgxODRWMTg0aDY1NnY2NTZ6IiAvPjwvc3ZnPg==) */
/**![plus](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQ4MiAxNTJoNjBxOCAwIDggOHY3MDRxMCA4LTggOGgtNjBxLTggMC04LThWMTYwcTAtOCA4LTh6IiAvPjxwYXRoIGQ9Ik0xOTIgNDc0aDY3MnE4IDAgOCA4djYwcTAgOC04IDhIMTYwcS04IDAtOC04di02MHEwLTggOC04eiIgLz48L3N2Zz4=) */
/**![question-circle](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEMyNjQuNiA2NCA2NCAyNjQuNiA2NCA1MTJzMjAwLjYgNDQ4IDQ0OCA0NDggNDQ4LTIwMC42IDQ0OC00NDhTNzU5LjQgNjQgNTEyIDY0em0wIDgyMGMtMjA1LjQgMC0zNzItMTY2LjYtMzcyLTM3MnMxNjYuNi0zNzIgMzcyLTM3MiAzNzIgMTY2LjYgMzcyIDM3Mi0xNjYuNiAzNzItMzcyIDM3MnoiIC8+PHBhdGggZD0iTTYyMy42IDMxNi43QzU5My42IDI5MC40IDU1NCAyNzYgNTEyIDI3NnMtODEuNiAxNC41LTExMS42IDQwLjdDMzY5LjIgMzQ0IDM1MiAzODAuNyAzNTIgNDIwdjcuNmMwIDQuNCAzLjYgOCA4IDhoNDhjNC40IDAgOC0zLjYgOC04VjQyMGMwLTQ0LjEgNDMuMS04MCA5Ni04MHM5NiAzNS45IDk2IDgwYzAgMzEuMS0yMiA1OS42LTU2LjEgNzIuNy0yMS4yIDguMS0zOS4yIDIyLjMtNTIuMSA0MC45LTEzLjEgMTktMTkuOSA0MS44LTE5LjkgNjQuOVY2MjBjMCA0LjQgMy42IDggOCA4aDQ4YzQuNCAwIDgtMy42IDgtOHYtMjIuN2E0OC4zIDQ4LjMgMCAwMTMwLjktNDQuOGM1OS0yMi43IDk3LjEtNzQuNyA5Ny4xLTEzMi41LjEtMzkuMy0xNy4xLTc2LTQ4LjMtMTAzLjN6TTQ3MiA3MzJhNDAgNDAgMCAxMDgwIDAgNDAgNDAgMCAxMC04MCAweiIgLz48L3N2Zz4=) */
/**![reload](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkwOS4xIDIwOS4zbC01Ni40IDQ0LjFDNzc1LjggMTU1LjEgNjU2LjIgOTIgNTIxLjkgOTIgMjkwIDkyIDEwMi4zIDI3OS41IDEwMiA1MTEuNSAxMDEuNyA3NDMuNyAyODkuOCA5MzIgNTIxLjkgOTMyYzE4MS4zIDAgMzM1LjgtMTE1IDM5NC42LTI3Ni4xIDEuNS00LjItLjctOC45LTQuOS0xMC4zbC01Ni43LTE5LjVhOCA4IDAgMDAtMTAuMSA0LjhjLTEuOCA1LTMuOCAxMC01LjkgMTQuOS0xNy4zIDQxLTQyLjEgNzcuOC03My43IDEwOS40QTM0NC43NyAzNDQuNzcgMCAwMTY1NS45IDgyOWMtNDIuMyAxNy45LTg3LjQgMjctMTMzLjggMjctNDYuNSAwLTkxLjUtOS4xLTEzMy44LTI3QTM0MS41IDM0MS41IDAgMDEyNzkgNzU1LjJhMzQyLjE2IDM0Mi4xNiAwIDAxLTczLjctMTA5LjRjLTE3LjktNDIuNC0yNy04Ny40LTI3LTEzMy45czkuMS05MS41IDI3LTEzMy45YzE3LjMtNDEgNDIuMS03Ny44IDczLjctMTA5LjQgMzEuNi0zMS42IDY4LjQtNTYuNCAxMDkuMy03My44IDQyLjMtMTcuOSA4Ny40LTI3IDEzMy44LTI3IDQ2LjUgMCA5MS41IDkuMSAxMzMuOCAyN2EzNDEuNSAzNDEuNSAwIDAxMTA5LjMgNzMuOGM5LjkgOS45IDE5LjIgMjAuNCAyNy44IDMxLjRsLTYwLjIgNDdhOCA4IDAgMDAzIDE0LjFsMTc1LjYgNDNjNSAxLjIgOS45LTIuNiA5LjktNy43bC44LTE4MC45Yy0uMS02LjYtNy44LTEwLjMtMTMtNi4yeiIgLz48L3N2Zz4=) */
/**![right](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTc2NS43IDQ4Ni44TDMxNC45IDEzNC43QTcuOTcgNy45NyAwIDAwMzAyIDE0MXY3Ny4zYzAgNC45IDIuMyA5LjYgNi4xIDEyLjZsMzYwIDI4MS4xLTM2MCAyODEuMWMtMy45IDMtNi4xIDcuNy02LjEgMTIuNlY4ODNjMCA2LjcgNy43IDEwLjQgMTIuOSA2LjNsNDUwLjgtMzUyLjFhMzEuOTYgMzEuOTYgMCAwMDAtNTAuNHoiIC8+PC9zdmc+) */
/**![robot](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMwMCAzMjhhNjAgNjAgMCAxMDEyMCAwIDYwIDYwIDAgMTAtMTIwIDB6TTg1MiA2NEgxNzJjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjY2MGMwIDE3LjcgMTQuMyAzMiAzMiAzMmg2ODBjMTcuNyAwIDMyLTE0LjMgMzItMzJWOTZjMC0xNy43LTE0LjMtMzItMzItMzJ6bS0zMiA2NjBIMjA0VjEyOGg2MTZ2NTk2ek02MDQgMzI4YTYwIDYwIDAgMTAxMjAgMCA2MCA2MCAwIDEwLTEyMCAwem0yNTAuMiA1NTZIMTY5LjhjLTE2LjUgMC0yOS44IDE0LjMtMjkuOCAzMnYzNmMwIDQuNCAzLjMgOCA3LjQgOGg3MjkuMWM0LjEgMCA3LjQtMy42IDcuNC04di0zNmMuMS0xNy43LTEzLjItMzItMjkuNy0zMnpNNjY0IDUwOEgzNjBjLTQuNCAwLTggMy42LTggOHY2MGMwIDQuNCAzLjYgOCA4IDhoMzA0YzQuNCAwIDgtMy42IDgtOHYtNjBjMC00LjQtMy42LTgtOC04eiIgLz48L3N2Zz4=) */
/**![rocket](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg2NCA3MzZjMC0xMTEuNi02NS40LTIwOC0xNjAtMjUyLjlWMzE3LjNjMC0xNS4xLTUuMy0yOS43LTE1LjEtNDEuMkw1MzYuNSA5NS40QzUzMC4xIDg3LjggNTIxIDg0IDUxMiA4NHMtMTguMSAzLjgtMjQuNSAxMS40TDMzNS4xIDI3Ni4xYTYzLjk3IDYzLjk3IDAgMDAtMTUuMSA0MS4ydjE2NS44QzIyNS40IDUyOCAxNjAgNjI0LjQgMTYwIDczNmgxNTYuNWMtMi4zIDcuMi0zLjUgMTUtMy41IDIzLjggMCAyMi4xIDcuNiA0My43IDIxLjQgNjAuOGE5Ny4yIDk3LjIgMCAwMDQzLjEgMzAuNmMyMy4xIDU0IDc1LjYgODguOCAxMzQuNSA4OC44IDI5LjEgMCA1Ny4zLTguNiA4MS40LTI0LjggMjMuNi0xNS44IDQxLjktMzcuOSA1My02NGE5NyA5NyAwIDAwNDMuMS0zMC41IDk3LjUyIDk3LjUyIDAgMDAyMS40LTYwLjhjMC04LjQtMS4xLTE2LjQtMy4xLTIzLjhIODY0ek03NjIuMyA2MjEuNGM5LjQgMTQuNiAxNyAzMC4zIDIyLjUgNDYuNkg3MDBWNTU4LjdhMjExLjYgMjExLjYgMCAwMTYyLjMgNjIuN3pNMzg4IDQ4My4xVjMxOC44bDEyNC0xNDcgMTI0IDE0N1Y2NjhIMzg4VjQ4My4xek0yMzkuMiA2NjhjNS41LTE2LjMgMTMuMS0zMiAyMi41LTQ2LjYgMTYuMy0yNS4yIDM3LjUtNDYuNSA2Mi4zLTYyLjdWNjY4aC04NC44em0zODguOSAxMTYuMmMtNS4yIDMtMTEuMiA0LjItMTcuMSAzLjRsLTE5LjUtMi40LTIuOCAxOS40Yy01LjQgMzcuOS0zOC40IDY2LjUtNzYuNyA2Ni41LTM4LjMgMC03MS4zLTI4LjYtNzYuNy02Ni41bC0yLjgtMTkuNS0xOS41IDIuNWEyNy43IDI3LjcgMCAwMS0xNy4xLTMuNWMtOC43LTUtMTQuMS0xNC4zLTE0LjEtMjQuNCAwLTEwLjYgNS45LTE5LjQgMTQuNi0yMy44aDIzMS4zYzguOCA0LjUgMTQuNiAxMy4zIDE0LjYgMjMuOC0uMSAxMC4yLTUuNSAxOS42LTE0LjIgMjQuNXpNNDY0IDQwMGE0OCA0OCAwIDEwOTYgMCA0OCA0OCAwIDEwLTk2IDB6IiAvPjwvc3ZnPg==) */
/**![safety](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjAgMCAxMDI0IDEwMjQiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUxMiA2NEwxMjggMTkydjM4NGMwIDIxMi4xIDE3MS45IDM4NCAzODQgMzg0czM4NC0xNzEuOSAzODQtMzg0VjE5Mkw1MTIgNjR6bTMxMiA1MTJjMCAxNzIuMy0xMzkuNyAzMTItMzEyIDMxMlMyMDAgNzQ4LjMgMjAwIDU3NlYyNDZsMzEyLTExMCAzMTIgMTEwdjMzMHoiIC8+PHBhdGggZD0iTTM3OC40IDQ3NS4xYTM1LjkxIDM1LjkxIDAgMDAtNTAuOSAwIDM1LjkxIDM1LjkxIDAgMDAwIDUwLjlsMTI5LjQgMTI5LjQgMi4xIDIuMWEzMy45OCAzMy45OCAwIDAwNDguMSAwTDczMC42IDQzNGEzMy45OCAzMy45OCAwIDAwMC00OC4xbC0yLjgtMi44YTMzLjk4IDMzLjk4IDAgMDAtNDguMSAwTDQ4MyA1NzkuNyAzNzguNCA0NzUuMXoiIC8+PC9zdmc+) */
/**![save](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg5My4zIDI5My4zTDczMC43IDEzMC43Yy03LjUtNy41LTE2LjctMTMtMjYuNy0xNlYxMTJIMTQ0Yy0xNy43IDAtMzIgMTQuMy0zMiAzMnY3MzZjMCAxNy43IDE0LjMgMzIgMzIgMzJoNzM2YzE3LjcgMCAzMi0xNC4zIDMyLTMyVjMzOC41YzAtMTctNi43LTMzLjItMTguNy00NS4yek0zODQgMTg0aDI1NnYxMDRIMzg0VjE4NHptNDU2IDY1NkgxODRWMTg0aDEzNnYxMzZjMCAxNy43IDE0LjMgMzIgMzIgMzJoMzIwYzE3LjcgMCAzMi0xNC4zIDMyLTMyVjIwNS44bDEzNiAxMzZWODQwek01MTIgNDQyYy03OS41IDAtMTQ0IDY0LjUtMTQ0IDE0NHM2NC41IDE0NCAxNDQgMTQ0IDE0NC02NC41IDE0NC0xNDQtNjQuNS0xNDQtMTQ0LTE0NHptMCAyMjRjLTQ0LjIgMC04MC0zNS44LTgwLTgwczM1LjgtODAgODAtODAgODAgMzUuOCA4MCA4MC0zNS44IDgwLTgwIDgweiIgLz48L3N2Zz4=) */
/**![search](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkwOS42IDg1NC41TDY0OS45IDU5NC44QzY5MC4yIDU0Mi43IDcxMiA0NzkgNzEyIDQxMmMwLTgwLjItMzEuMy0xNTUuNC04Ny45LTIxMi4xLTU2LjYtNTYuNy0xMzItODcuOS0yMTIuMS04Ny45cy0xNTUuNSAzMS4zLTIxMi4xIDg3LjlDMTQzLjIgMjU2LjUgMTEyIDMzMS44IDExMiA0MTJjMCA4MC4xIDMxLjMgMTU1LjUgODcuOSAyMTIuMUMyNTYuNSA2ODAuOCAzMzEuOCA3MTIgNDEyIDcxMmM2NyAwIDEzMC42LTIxLjggMTgyLjctNjJsMjU5LjcgMjU5LjZhOC4yIDguMiAwIDAwMTEuNiAwbDQzLjYtNDMuNWE4LjIgOC4yIDAgMDAwLTExLjZ6TTU3MC40IDU3MC40QzUyOCA2MTIuNyA0NzEuOCA2MzYgNDEyIDYzNnMtMTE2LTIzLjMtMTU4LjQtNjUuNkMyMTEuMyA1MjggMTg4IDQ3MS44IDE4OCA0MTJzMjMuMy0xMTYuMSA2NS42LTE1OC40QzI5NiAyMTEuMyAzNTIuMiAxODggNDEyIDE4OHMxMTYuMSAyMy4yIDE1OC40IDY1LjZTNjM2IDM1Mi4yIDYzNiA0MTJzLTIzLjMgMTE2LjEtNjUuNiAxNTguNHoiIC8+PC9zdmc+) */
/**![send](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PHN0eWxlIC8+PC9kZWZzPjxwYXRoIGQ9Ik05MzEuNCA0OTguOUw5NC45IDc5LjVjLTMuNC0xLjctNy4zLTIuMS0xMS0xLjJhMTUuOTkgMTUuOTkgMCAwMC0xMS43IDE5LjNsODYuMiAzNTIuMmMxLjMgNS4zIDUuMiA5LjYgMTAuNCAxMS4zbDE0Ny43IDUwLjctMTQ3LjYgNTAuN2MtNS4yIDEuOC05LjEgNi0xMC4zIDExLjNMNzIuMiA5MjYuNWMtLjkgMy43LS41IDcuNiAxLjIgMTAuOSAzLjkgNy45IDEzLjUgMTEuMSAyMS41IDcuMmw4MzYuNS00MTdjMy4xLTEuNSA1LjYtNC4xIDcuMi03LjEgMy45LTggLjctMTcuNi03LjItMjEuNnpNMTcwLjggODI2LjNsNTAuMy0yMDUuNiAyOTUuMi0xMDEuM2MyLjMtLjggNC4yLTIuNiA1LTUgMS40LTQuMi0uOC04LjctNS0xMC4yTDIyMS4xIDQwMyAxNzEgMTk4LjJsNjI4IDMxNC45LTYyOC4yIDMxMy4yeiIgLz48L3N2Zz4=) */
/**![setting](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkyNC44IDYyNS43bC02NS41LTU2YzMuMS0xOSA0LjctMzguNCA0LjctNTcuOHMtMS42LTM4LjgtNC43LTU3LjhsNjUuNS01NmEzMi4wMyAzMi4wMyAwIDAwOS4zLTM1LjJsLS45LTIuNmE0NDMuNzQgNDQzLjc0IDAgMDAtNzkuNy0xMzcuOWwtMS44LTIuMWEzMi4xMiAzMi4xMiAwIDAwLTM1LjEtOS41bC04MS4zIDI4LjljLTMwLTI0LjYtNjMuNS00NC05OS43LTU3LjZsLTE1LjctODVhMzIuMDUgMzIuMDUgMCAwMC0yNS44LTI1LjdsLTIuNy0uNWMtNTIuMS05LjQtMTA2LjktOS40LTE1OSAwbC0yLjcuNWEzMi4wNSAzMi4wNSAwIDAwLTI1LjggMjUuN2wtMTUuOCA4NS40YTM1MS44NiAzNTEuODYgMCAwMC05OSA1Ny40bC04MS45LTI5LjFhMzIgMzIgMCAwMC0zNS4xIDkuNWwtMS44IDIuMWE0NDYuMDIgNDQ2LjAyIDAgMDAtNzkuNyAxMzcuOWwtLjkgMi42Yy00LjUgMTIuNS0uOCAyNi41IDkuMyAzNS4ybDY2LjMgNTYuNmMtMy4xIDE4LjgtNC42IDM4LTQuNiA1Ny4xIDAgMTkuMiAxLjUgMzguNCA0LjYgNTcuMUw5OSA2MjUuNWEzMi4wMyAzMi4wMyAwIDAwLTkuMyAzNS4ybC45IDIuNmMxOC4xIDUwLjQgNDQuOSA5Ni45IDc5LjcgMTM3LjlsMS44IDIuMWEzMi4xMiAzMi4xMiAwIDAwMzUuMSA5LjVsODEuOS0yOS4xYzI5LjggMjQuNSA2My4xIDQzLjkgOTkgNTcuNGwxNS44IDg1LjRhMzIuMDUgMzIuMDUgMCAwMDI1LjggMjUuN2wyLjcuNWE0NDkuNCA0NDkuNCAwIDAwMTU5IDBsMi43LS41YTMyLjA1IDMyLjA1IDAgMDAyNS44LTI1LjdsMTUuNy04NWEzNTAgMzUwIDAgMDA5OS43LTU3LjZsODEuMyAyOC45YTMyIDMyIDAgMDAzNS4xLTkuNWwxLjgtMi4xYzM0LjgtNDEuMSA2MS42LTg3LjUgNzkuNy0xMzcuOWwuOS0yLjZjNC41LTEyLjMuOC0yNi4zLTkuMy0zNXpNNzg4LjMgNDY1LjljMi41IDE1LjEgMy44IDMwLjYgMy44IDQ2LjFzLTEuMyAzMS0zLjggNDYuMWwtNi42IDQwLjEgNzQuNyA2My45YTM3MC4wMyAzNzAuMDMgMCAwMS00Mi42IDczLjZMNzIxIDcwMi44bC0zMS40IDI1LjhjLTIzLjkgMTkuNi01MC41IDM1LTc5LjMgNDUuOGwtMzguMSAxNC4zLTE3LjkgOTdhMzc3LjUgMzc3LjUgMCAwMS04NSAwbC0xNy45LTk3LjItMzcuOC0xNC41Yy0yOC41LTEwLjgtNTUtMjYuMi03OC43LTQ1LjdsLTMxLjQtMjUuOS05My40IDMzLjJjLTE3LTIyLjktMzEuMi00Ny42LTQyLjYtNzMuNmw3NS41LTY0LjUtNi41LTQwYy0yLjQtMTQuOS0zLjctMzAuMy0zLjctNDUuNSAwLTE1LjMgMS4yLTMwLjYgMy43LTQ1LjVsNi41LTQwLTc1LjUtNjQuNWMxMS4zLTI2LjEgMjUuNi01MC43IDQyLjYtNzMuNmw5My40IDMzLjIgMzEuNC0yNS45YzIzLjctMTkuNSA1MC4yLTM0LjkgNzguNy00NS43bDM3LjktMTQuMyAxNy45LTk3LjJjMjguMS0zLjIgNTYuOC0zLjIgODUgMGwxNy45IDk3IDM4LjEgMTQuM2MyOC43IDEwLjggNTUuNCAyNi4yIDc5LjMgNDUuOGwzMS40IDI1LjggOTIuOC0zMi45YzE3IDIyLjkgMzEuMiA0Ny42IDQyLjYgNzMuNkw3ODEuOCA0MjZsNi41IDM5Ljl6TTUxMiAzMjZjLTk3LjIgMC0xNzYgNzguOC0xNzYgMTc2czc4LjggMTc2IDE3NiAxNzYgMTc2LTc4LjggMTc2LTE3Ni03OC44LTE3Ni0xNzYtMTc2em03OS4yIDI1NS4yQTExMS42IDExMS42IDAgMDE1MTIgNjE0Yy0yOS45IDAtNTgtMTEuNy03OS4yLTMyLjhBMTExLjYgMTExLjYgMCAwMTQwMCA1MDJjMC0yOS45IDExLjctNTggMzIuOC03OS4yQzQ1NCA0MDEuNiA0ODIuMSAzOTAgNTEyIDM5MGMyOS45IDAgNTggMTEuNiA3OS4yIDMyLjhBMTExLjYgMTExLjYgMCAwMTYyNCA1MDJjMCAyOS45LTExLjcgNTgtMzIuOCA3OS4yeiIgLz48L3N2Zz4=) */
/**![sync](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2OCA1MDQuMmMxLTQzLjcgMTAtODYuMSAyNi45LTEyNiAxNy4zLTQxIDQyLjEtNzcuNyA3My43LTEwOS40UzMzNyAyMTIuMyAzNzggMTk1YzQyLjQtMTcuOSA4Ny40LTI3IDEzMy45LTI3czkxLjUgOS4xIDEzMy44IDI3QTM0MS41IDM0MS41IDAgMDE3NTUgMjY4LjhjOS45IDkuOSAxOS4yIDIwLjQgMjcuOCAzMS40bC02MC4yIDQ3YTggOCAwIDAwMyAxNC4xbDE3NS43IDQzYzUgMS4yIDkuOS0yLjYgOS45LTcuN2wuOC0xODAuOWMwLTYuNy03LjctMTAuNS0xMi45LTYuM2wtNTYuNCA0NC4xQzc2NS44IDE1NS4xIDY0Ni4yIDkyIDUxMS44IDkyIDI4Mi43IDkyIDk2LjMgMjc1LjYgOTIgNTAzLjhhOCA4IDAgMDA4IDguMmg2MGM0LjQgMCA3LjktMy41IDgtNy44em03NTYgNy44aC02MGMtNC40IDAtNy45IDMuNS04IDcuOC0xIDQzLjctMTAgODYuMS0yNi45IDEyNi0xNy4zIDQxLTQyLjEgNzcuOC03My43IDEwOS40QTM0Mi40NSAzNDIuNDUgMCAwMTUxMi4xIDg1NmEzNDIuMjQgMzQyLjI0IDAgMDEtMjQzLjItMTAwLjhjLTkuOS05LjktMTkuMi0yMC40LTI3LjgtMzEuNGw2MC4yLTQ3YTggOCAwIDAwLTMtMTQuMWwtMTc1LjctNDNjLTUtMS4yLTkuOSAyLjYtOS45IDcuN2wtLjcgMTgxYzAgNi43IDcuNyAxMC41IDEyLjkgNi4zbDU2LjQtNDQuMUMyNTguMiA4NjguOSAzNzcuOCA5MzIgNTEyLjIgOTMyYzIyOS4yIDAgNDE1LjUtMTgzLjcgNDE5LjgtNDExLjhhOCA4IDAgMDAtOC04LjJ6IiAvPjwvc3ZnPg==) */
/**![thunderbolt](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg0OCAzNTkuM0g2MjcuN0w4MjUuOCAxMDljNC4xLTUuMy40LTEzLTYuMy0xM0g0MzZjLTIuOCAwLTUuNSAxLjUtNi45IDRMMTcwIDU0Ny41Yy0zLjEgNS4zLjcgMTIgNi45IDEyaDE3NC40bC04OS40IDM1Ny42Yy0xLjkgNy44IDcuNSAxMy4zIDEzLjMgNy43TDg1My41IDM3M2M1LjItNC45IDEuNy0xMy43LTUuNS0xMy43ek0zNzguMiA3MzIuNWw2MC4zLTI0MUgyODEuMWwxODkuNi0zMjcuNGgyMjQuNkw0ODcgNDI3LjRoMjExTDM3OC4yIDczMi41eiIgLz48L3N2Zz4=) */
/**![up](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg5MC41IDc1NS4zTDUzNy45IDI2OS4yYy0xMi44LTE3LjYtMzktMTcuNi01MS43IDBMMTMzLjUgNzU1LjNBOCA4IDAgMDAxNDAgNzY4aDc1YzUuMSAwIDkuOS0yLjUgMTIuOS02LjZMNTEyIDM2OS44bDI4NC4xIDM5MS42YzMgNC4xIDcuOCA2LjYgMTIuOSA2LjZoNzVjNi41IDAgMTAuMy03LjQgNi41LTEyLjd6IiAvPjwvc3ZnPg==) */
/**![user](data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTAiIGhlaWdodD0iNTAiIGZpbGw9IiNjYWNhY2EiIHZpZXdCb3g9IjY0IDY0IDg5NiA4OTYiIGZvY3VzYWJsZT0iZmFsc2UiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTg1OC41IDc2My42YTM3NCAzNzQgMCAwMC04MC42LTExOS41IDM3NS42MyAzNzUuNjMgMCAwMC0xMTkuNS04MC42Yy0uNC0uMi0uOC0uMy0xLjItLjVDNzE5LjUgNTE4IDc2MCA0NDQuNyA3NjAgMzYyYzAtMTM3LTExMS0yNDgtMjQ4LTI0OFMyNjQgMjI1IDI2NCAzNjJjMCA4Mi43IDQwLjUgMTU2IDEwMi44IDIwMS4xLS40LjItLjguMy0xLjIuNS00NC44IDE4LjktODUgNDYtMTE5LjUgODAuNmEzNzUuNjMgMzc1LjYzIDAgMDAtODAuNiAxMTkuNUEzNzEuNyAzNzEuNyAwIDAwMTM2IDkwMS44YTggOCAwIDAwOCA4LjJoNjBjNC40IDAgNy45LTMuNSA4LTcuOCAyLTc3LjIgMzMtMTQ5LjUgODcuOC0yMDQuMyA1Ni43LTU2LjcgMTMyLTg3LjkgMjEyLjItODcuOXMxNTUuNSAzMS4yIDIxMi4yIDg3LjlDNzc5IDc1Mi43IDgxMCA4MjUgODEyIDkwMi4yYy4xIDQuNCAzLjYgNy44IDggNy44aDYwYTggOCAwIDAwOC04LjJjLTEtNDcuOC0xMC45LTk0LjMtMjkuNS0xMzguMnpNNTEyIDUzNGMtNDUuOSAwLTg5LjEtMTcuOS0xMjEuNi01MC40UzM0MCA0MDcuOSAzNDAgMzYyYzAtNDUuOSAxNy45LTg5LjEgNTAuNC0xMjEuNlM0NjYuMSAxOTAgNTEyIDE5MHM4OS4xIDE3LjkgMTIxLjYgNTAuNFM2ODQgMzE2LjEgNjg0IDM2MmMwIDQ1LjktMTcuOSA4OS4xLTUwLjQgMTIxLjZTNTU3LjkgNTM0IDUxMiA1MzR6IiAvPjwvc3ZnPg==) */

File diff suppressed because one or more lines are too long

20080
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

69
src/App.css Normal file
View 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
View 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
View 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
View 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;

View File

@@ -0,0 +1,12 @@
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.spin-animation {
animation: spin 3s linear infinite;
}

View 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;

View 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
View 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
View 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
View 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

View 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:messageim: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
View 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
View 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

File diff suppressed because it is too large Load Diff

1339
src/pages/NovelGenerate.tsx Normal file

File diff suppressed because it is too large Load Diff

414
src/pages/NovelList.tsx Normal file
View 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
View 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
View 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
View 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-20.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-10.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-80002000</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">510203050100</Text>
</div>
<div style={{ marginTop: '4px' }}>
<Text strong></Text>
<Text type="secondary"></Text>
</div>
</div>
)
},
{
title: '步骤 3AI 生成基础设定',
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
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

15
src/reportWebVitals.ts Normal file
View 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
View 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
View 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
View 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)
};

View 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
View 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
View 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"
]
}