前端修饰与渲染处理
This commit is contained in:
7
.buildkit.toml
Normal file
7
.buildkit.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Docker BuildKit configuration for China users
|
||||
[build]
|
||||
# Use BuildKit for faster builds
|
||||
BUILDKIT_STEP_LOG_MAX_SIZE = 10485760
|
||||
|
||||
[registry."docker.io"]
|
||||
mirrors = ["docker.mirrors.ustc.edu.cn", "registry.docker-cn.com"]
|
||||
116
.dockerignore
Normal file
116
.dockerignore
Normal file
@@ -0,0 +1,116 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
.nyc_output
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
.env.local
|
||||
|
||||
# parcel-cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# Test files
|
||||
test/
|
||||
tests/
|
||||
__tests__/
|
||||
*.test.js
|
||||
*.spec.js
|
||||
coverage/
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
@@ -5,11 +5,3 @@ FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Data Directory
|
||||
DATA_DIR=./data
|
||||
|
||||
# LLM API Keys (配置你使用的 LLM 提供商)
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
|
||||
# Optional: Custom LLM Provider Settings
|
||||
LLM_PROVIDER=openai # openai | anthropic | custom
|
||||
LLM_MODEL=gpt-4 # 根据你的 provider 选择合适的模型
|
||||
|
||||
315
DISTRIBUTION-CHECKLIST.md
Normal file
315
DISTRIBUTION-CHECKLIST.md
Normal file
@@ -0,0 +1,315 @@
|
||||
# 📦 项目分发清单
|
||||
|
||||
## ✅ 已完成配置
|
||||
|
||||
### 1. Docker 配置
|
||||
- ✅ `docker-compose.yml` - 开发/生产通用配置
|
||||
- ✅ `docker-compose.prod.yml` - 生产环境配置(使用预构建镜像)
|
||||
- ✅ `docker/backend.Dockerfile` - 后端镜像构建
|
||||
- ✅ `docker/frontend.Dockerfile` - 前端镜像构建(Nginx)
|
||||
- ✅ `.dockerignore` - 优化构建速度
|
||||
|
||||
### 2. npm 配置(中国大陆优化)
|
||||
- ✅ `server/.npmrc` - 后端 npm 镜像源
|
||||
- ✅ `client/.npmrc` - 前端 npm 镜像源
|
||||
- ✅ 自动 fallback 机制(阿里云 → 腾讯云 → 华为云)
|
||||
|
||||
### 3. 构建脚本
|
||||
- ✅ `build-docker.ps1` - PowerShell 构建脚本
|
||||
- ✅ `build-docker.bat` - CMD 构建脚本
|
||||
- ✅ `build-docker.sh` - Linux/Mac 构建脚本
|
||||
- ✅ `publish-docker.bat` - Docker Hub 发布脚本
|
||||
|
||||
### 4. 开发工具
|
||||
- ✅ `start-dev.ps1` - 本地开发启动脚本
|
||||
- ✅ `start-dev.bat` - CMD 开发启动脚本
|
||||
- ✅ `switch-npm-mirror.bat` - 切换 npm 镜像源
|
||||
- ✅ `test-npm-registry.bat` - 测试 npm 连接
|
||||
|
||||
### 5. 文档
|
||||
- ✅ `README.md` - 项目介绍
|
||||
- ✅ `USER-GUIDE.md` - 用户快速开始指南
|
||||
- ✅ `DISTRIBUTION-GUIDE.md` - 分发指南
|
||||
- ✅ `DISTRIBUTION-STRATEGY.md` - 分发策略详解
|
||||
- ✅ `TROUBLESHOOTING.md` - 故障排除
|
||||
- ✅ `LOCAL-DEV-GUIDE.md` - 本地开发指南
|
||||
- ✅ `DOCKER-BUILD-GUIDE.md` - Docker 构建指南
|
||||
|
||||
---
|
||||
|
||||
## 🎯 分发方式选择
|
||||
|
||||
### 方式 1:GitHub 仓库(推荐用于开源)
|
||||
|
||||
**包含文件:**
|
||||
```
|
||||
sillytavern-repalice/
|
||||
├── docker/
|
||||
│ ├── backend.Dockerfile
|
||||
│ └── frontend.Dockerfile
|
||||
├── server/ # 后端源码
|
||||
├── client/ # 前端源码
|
||||
├── shared/ # 共享模块
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .dockerignore
|
||||
├── package.json
|
||||
├── USER-GUIDE.md # ⭐ 用户必读
|
||||
├── README.md
|
||||
└── ...其他配置文件
|
||||
```
|
||||
|
||||
**用户操作:**
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd sillytavern-repalice
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 完全透明
|
||||
- ✅ 用户可以自定义
|
||||
- ✅ 便于协作开发
|
||||
|
||||
---
|
||||
|
||||
### 方式 2:Docker Hub(推荐用于正式发布)
|
||||
|
||||
**步骤 1:推送镜像到 Docker Hub**
|
||||
|
||||
```bash
|
||||
# 登录
|
||||
docker login
|
||||
|
||||
# 运行发布脚本
|
||||
.\publish-docker.bat
|
||||
# 输入版本号,例如:1.0.0
|
||||
```
|
||||
|
||||
**步骤 2:提供给用户的文件**
|
||||
|
||||
创建简化的分发包:
|
||||
```
|
||||
sillytavern-repalice-release/
|
||||
├── docker-compose.prod.yml # 重命名为 docker-compose.yml
|
||||
├── .env.example
|
||||
├── USER-GUIDE.md # ⭐ 用户必读
|
||||
└── README.md
|
||||
```
|
||||
|
||||
修改 `docker-compose.prod.yml` 中的镜像地址:
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: yourusername/sillytavern-repalice-backend:latest
|
||||
|
||||
frontend:
|
||||
image: yourusername/sillytavern-repalice-frontend:latest
|
||||
```
|
||||
|
||||
**用户操作:**
|
||||
```bash
|
||||
# 下载分发包
|
||||
# 解压
|
||||
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- ✅ 启动速度快(无需构建)
|
||||
- ✅ 用户操作简单
|
||||
- ✅ 版本管理清晰
|
||||
|
||||
---
|
||||
|
||||
### 方式 3:离线镜像(内网环境)
|
||||
|
||||
**步骤 1:导出镜像**
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
docker-compose build
|
||||
|
||||
# 导出
|
||||
docker save sillytavern-repalice-backend:latest -o backend.tar
|
||||
docker save sillytavern-repalice-frontend:latest -o frontend.tar
|
||||
|
||||
# 压缩
|
||||
tar -czf sillytavern-images.tar.gz backend.tar frontend.tar
|
||||
```
|
||||
|
||||
**步骤 2:提供文件**
|
||||
|
||||
```
|
||||
sillytavern-offline/
|
||||
├── sillytavern-images.tar.gz
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── USER-GUIDE.md
|
||||
└── import-images.bat/psh # 导入脚本
|
||||
```
|
||||
|
||||
**用户操作:**
|
||||
```bash
|
||||
# 解压镜像
|
||||
tar -xzf sillytavern-images.tar.gz
|
||||
|
||||
# 导入
|
||||
docker load -i backend.tar
|
||||
docker load -i frontend.tar
|
||||
|
||||
# 配置
|
||||
cp .env.example .env
|
||||
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 分发前检查清单
|
||||
|
||||
### 代码检查
|
||||
- [ ] 所有功能测试通过
|
||||
- [ ] 没有硬编码的敏感信息
|
||||
- [ ] `.env` 在 `.gitignore` 中
|
||||
- [ ] `node_modules` 在 `.dockerignore` 中
|
||||
|
||||
### Docker 检查
|
||||
- [ ] `docker-compose up -d` 能正常启动
|
||||
- [ ] 前后端都能正常访问
|
||||
- [ ] 数据持久化正常工作
|
||||
- [ ] 日志输出清晰
|
||||
|
||||
### 文档检查
|
||||
- [ ] `USER-GUIDE.md` 完整准确
|
||||
- [ ] `.env.example` 包含所有必要配置
|
||||
- [ ] README 指向正确的文档
|
||||
- [ ] 常见问题已覆盖
|
||||
|
||||
### 镜像检查(如使用 Docker Hub)
|
||||
- [ ] 镜像可以成功推送
|
||||
- [ ] 镜像可以从 Docker Hub 拉取
|
||||
- [ ] 版本号标签正确
|
||||
- [ ] `latest` 标签指向最新版本
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速分发流程
|
||||
|
||||
### 对于开发者
|
||||
|
||||
```bash
|
||||
# 1. 确保代码最新
|
||||
git add .
|
||||
git commit -m "Release version 1.0.0"
|
||||
git push
|
||||
|
||||
# 2. 构建并推送镜像(可选)
|
||||
.\publish-docker.bat
|
||||
# 输入:1.0.0
|
||||
|
||||
# 3. 创建 Release Tag
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### 对于用户
|
||||
|
||||
**从 GitHub:**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sillytavern-repalice.git
|
||||
cd sillytavern-repalice
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**从 Docker Hub:**
|
||||
```bash
|
||||
# 下载 docker-compose.prod.yml 和 .env.example
|
||||
# 重命名 docker-compose.prod.yml 为 docker-compose.yml
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比
|
||||
|
||||
| 特性 | GitHub | Docker Hub | 离线镜像 |
|
||||
|------|--------|------------|----------|
|
||||
| 设置难度 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| 启动速度 | 🐌 慢 | ⚡ 快 | ⚡ 快 |
|
||||
| 文件大小 | 🟢 小 | 🟡 中 | 🔴 大 |
|
||||
| 可定制性 | ✅ 高 | ❌ 低 | ❌ 低 |
|
||||
| 适用场景 | 开源/开发 | 正式发布 | 内网 |
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践建议
|
||||
|
||||
### 1. 同时提供多种方式
|
||||
- GitHub 仓库(主要)
|
||||
- Docker Hub 镜像(可选)
|
||||
- 离线包(按需)
|
||||
|
||||
### 2. 文档优先
|
||||
- `USER-GUIDE.md` 必须清晰简洁
|
||||
- 提供截图或视频教程
|
||||
- 常见问题单独列出
|
||||
|
||||
### 3. 简化用户操作
|
||||
- 提供一键启动脚本
|
||||
- 默认配置尽可能合理
|
||||
- 错误提示友好清晰
|
||||
|
||||
### 4. 版本管理
|
||||
- 使用语义化版本号
|
||||
- CHANGELOG 记录变更
|
||||
- 保持向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐方案
|
||||
|
||||
**对于大多数项目,推荐组合使用:**
|
||||
|
||||
1. **主要分发**:GitHub 仓库
|
||||
- 完整源码
|
||||
- 详细文档
|
||||
- 适合开发和定制
|
||||
|
||||
2. **快速部署**:Docker Hub 镜像
|
||||
- 一键启动
|
||||
- 适合生产环境
|
||||
- 定期更新
|
||||
|
||||
3. **特殊需求**:离线镜像
|
||||
- 内网部署
|
||||
- 安全要求高的环境
|
||||
|
||||
---
|
||||
|
||||
## 📞 用户支持
|
||||
|
||||
提供以下支持渠道:
|
||||
- 📖 完整文档(USER-GUIDE.md)
|
||||
- 🐛 Issue 追踪
|
||||
- 💬 讨论区
|
||||
- 📧 联系方式
|
||||
|
||||
---
|
||||
|
||||
**祝您分发顺利!** 🎉
|
||||
|
||||
如有问题,请参考:
|
||||
- [用户指南](USER-GUIDE.md)
|
||||
- [故障排除](TROUBLESHOOTING.md)
|
||||
- [分发策略](DISTRIBUTION-STRATEGY.md)
|
||||
112
DISTRIBUTION-GUIDE.md
Normal file
112
DISTRIBUTION-GUIDE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# SillyTavern Repalice - 快速启动指南
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- 安装 [Docker](https://www.docker.com/products/docker-desktop/)
|
||||
- 安装 [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
### 启动应用
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone <repository-url>
|
||||
cd sillytavern-repalice
|
||||
|
||||
# 配置环境变量(可选)
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入您的 API 密钥
|
||||
|
||||
# 启动应用
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 访问应用
|
||||
|
||||
- **前端**: http://localhost:23337
|
||||
- **后端**: http://localhost:3000
|
||||
|
||||
### 停止应用
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 📝 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```env
|
||||
# LLM API 配置
|
||||
OPENAI_API_KEY=your-api-key-here
|
||||
ANTHROPIC_API_KEY=your-api-key-here
|
||||
|
||||
# 其他配置
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
### 数据持久化
|
||||
|
||||
所有数据存储在 `./data` 目录,包括:
|
||||
- 角色卡
|
||||
- 聊天记录
|
||||
- 世界书
|
||||
- 预设配置
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### Q: 端口被占用怎么办?
|
||||
|
||||
修改 `docker-compose.yml` 中的端口映射:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "23338:80" # 将前端改为 23338 端口
|
||||
- "8081:3000" # 将后端改为 8081 端口
|
||||
```
|
||||
|
||||
### Q: 如何更新到最新版本?
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Q: 如何查看日志?
|
||||
|
||||
```bash
|
||||
# 查看所有日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 只查看前端日志
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# 只查看后端日志
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
### Q: 如何备份数据?
|
||||
|
||||
```bash
|
||||
# 备份 data 目录
|
||||
tar -czf backup.tar.gz ./data
|
||||
|
||||
# 恢复数据
|
||||
tar -xzf backup.tar.gz
|
||||
```
|
||||
|
||||
## 📞 获取帮助
|
||||
|
||||
如有问题,请提交 Issue 或联系开发者。
|
||||
|
||||
---
|
||||
|
||||
**祝您使用愉快!** 🎉
|
||||
359
DISTRIBUTION-STRATEGY.md
Normal file
359
DISTRIBUTION-STRATEGY.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# 📦 项目分发指南
|
||||
|
||||
## 🎯 推荐方案:保持双容器架构(Vite + Nginx)
|
||||
|
||||
### 为什么选择双容器?
|
||||
|
||||
1. **性能最优**:Nginx 是专业的静态文件服务器,性能远超 Node.js
|
||||
2. **职责清晰**:前后端分离,便于维护和扩展
|
||||
3. **行业标准**:这是业界最佳实践
|
||||
4. **部署简单**:用户只需一条命令 `docker-compose up -d`
|
||||
|
||||
---
|
||||
|
||||
## 📋 分发方式
|
||||
|
||||
### 方式 1:提供源代码 + docker-compose.yml(最简单)
|
||||
|
||||
**适用场景**:开源项目、团队协作
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. 确保以下文件在仓库中:
|
||||
- `docker-compose.yml`
|
||||
- `docker/backend.Dockerfile`
|
||||
- `docker/frontend.Dockerfile`
|
||||
- `.env.example`
|
||||
- `DISTRIBUTION-GUIDE.md`
|
||||
|
||||
2. 用户操作流程:
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone <repository-url>
|
||||
cd sillytavern-repalice
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件
|
||||
|
||||
# 启动应用
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 最简单,无需额外配置
|
||||
- ✅ 用户可以自定义修改
|
||||
- ✅ 透明度高
|
||||
|
||||
**缺点**:
|
||||
- ❌ 首次构建较慢
|
||||
- ❌ 需要安装 Git
|
||||
|
||||
---
|
||||
|
||||
### 方式 2:推送到 Docker Hub(推荐)
|
||||
|
||||
**适用场景**:公开发布、简化用户操作
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **构建并推送镜像**:
|
||||
```bash
|
||||
# 登录 Docker Hub
|
||||
docker login
|
||||
|
||||
# 运行发布脚本
|
||||
.\publish-docker.bat
|
||||
# 输入版本号,例如:1.0.0
|
||||
```
|
||||
|
||||
2. **创建简化的 docker-compose.yml**(给用户):
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: yourusername/sillytavern-repalice-backend:latest
|
||||
container_name: sillytavern-backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DATA_DIR=/app/data
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: yourusername/sillytavern-repalice-frontend:latest
|
||||
container_name: sillytavern-frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3000/api
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
3. **用户操作流程**:
|
||||
```bash
|
||||
# 下载 docker-compose.yml 和 .env.example
|
||||
# 配置 .env 文件
|
||||
|
||||
# 直接启动(自动拉取镜像)
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 启动速度快(无需构建)
|
||||
- ✅ 用户操作简单
|
||||
- ✅ 版本管理清晰
|
||||
|
||||
**缺点**:
|
||||
- ❌ 需要 Docker Hub 账号
|
||||
- ❌ 镜像占用存储空间
|
||||
|
||||
---
|
||||
|
||||
### 方式 3:导出镜像文件(离线分发)
|
||||
|
||||
**适用场景**:内网环境、无法访问 Docker Hub
|
||||
|
||||
**步骤**:
|
||||
|
||||
1. **构建并导出镜像**:
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker-compose build
|
||||
|
||||
# 导出为 tar 文件
|
||||
docker save sillytavern-repalice-backend:latest -o backend.tar
|
||||
docker save sillytavern-repalice-frontend:latest -o frontend.tar
|
||||
|
||||
# 压缩
|
||||
tar -czf sillytavern-images.tar.gz backend.tar frontend.tar
|
||||
```
|
||||
|
||||
2. **用户提供**:
|
||||
- `sillytavern-images.tar.gz`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
- 导入脚本
|
||||
|
||||
3. **用户操作流程**:
|
||||
```bash
|
||||
# 解压
|
||||
tar -xzf sillytavern-images.tar.gz
|
||||
|
||||
# 导入镜像
|
||||
docker load -i backend.tar
|
||||
docker load -i frontend.tar
|
||||
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 完全离线可用
|
||||
- ✅ 适合内网环境
|
||||
|
||||
**缺点**:
|
||||
- ❌ 文件体积大
|
||||
- ❌ 分发不便
|
||||
|
||||
---
|
||||
|
||||
## 📝 分发包结构
|
||||
|
||||
### 最小化分发包
|
||||
|
||||
```
|
||||
sillytavern-repalice/
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── .env.example # 环境变量示例
|
||||
├── DISTRIBUTION-GUIDE.md # 分发指南
|
||||
└── README.md # 使用说明
|
||||
```
|
||||
|
||||
### 完整分发包(含源码)
|
||||
|
||||
```
|
||||
sillytavern-repalice/
|
||||
├── docker/
|
||||
│ ├── backend.Dockerfile
|
||||
│ └── frontend.Dockerfile
|
||||
├── server/ # 后端源码
|
||||
├── client/ # 前端源码
|
||||
├── shared/ # 共享模块
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── .dockerignore
|
||||
├── package.json
|
||||
├── DISTRIBUTION-GUIDE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 用户快速开始模板
|
||||
|
||||
创建一个 `QUICKSTART.md`:
|
||||
|
||||
```markdown
|
||||
# 快速开始
|
||||
|
||||
## 1. 前置要求
|
||||
|
||||
- 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/)
|
||||
|
||||
## 2. 配置
|
||||
|
||||
```bash
|
||||
# 复制环境变量文件
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env,填入您的 API 密钥
|
||||
notepad .env
|
||||
```
|
||||
|
||||
## 3. 启动
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 4. 访问
|
||||
|
||||
- 前端:http://localhost:5173
|
||||
- 后端:http://localhost:3000
|
||||
|
||||
## 5. 停止
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践建议
|
||||
|
||||
### 1. 版本管理
|
||||
|
||||
使用语义化版本号:
|
||||
- `v1.0.0` - 主版本
|
||||
- `v1.1.0` - 次版本
|
||||
- `v1.0.1` - 补丁版本
|
||||
|
||||
### 2. 文档完善
|
||||
|
||||
提供以下文档:
|
||||
- `README.md` - 项目介绍
|
||||
- `DISTRIBUTION-GUIDE.md` - 分发指南
|
||||
- `QUICKSTART.md` - 快速开始
|
||||
- `.env.example` - 环境变量说明
|
||||
|
||||
### 3. 自动化脚本
|
||||
|
||||
提供便捷脚本:
|
||||
- `build-docker.ps1` - 构建脚本
|
||||
- `publish-docker.bat` - 发布脚本
|
||||
- `start-dev.ps1` - 开发启动脚本
|
||||
|
||||
### 4. CI/CD(可选)
|
||||
|
||||
配置 GitHub Actions 自动构建和推送:
|
||||
|
||||
```yaml
|
||||
name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
docker-compose build
|
||||
docker-compose push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 方案对比总结
|
||||
|
||||
| 方案 | 复杂度 | 启动速度 | 适用场景 |
|
||||
|------|--------|----------|----------|
|
||||
| 源码 + docker-compose | ⭐ | 🐌 慢 | 开源项目、开发团队 |
|
||||
| Docker Hub 镜像 | ⭐⭐ | ⚡ 快 | 公开发布、生产环境 |
|
||||
| 离线镜像文件 | ⭐⭐⭐ | ⚡ 快 | 内网环境、安全要求高 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 我的推荐
|
||||
|
||||
**对于大多数场景,推荐使用方式 2(Docker Hub 镜像)**:
|
||||
|
||||
1. **开发者**:
|
||||
```bash
|
||||
# 构建并发布
|
||||
.\publish-docker.bat
|
||||
```
|
||||
|
||||
2. **用户**:
|
||||
```bash
|
||||
# 下载配置文件
|
||||
# 配置 .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 用户体验最好(启动快)
|
||||
- ✅ 维护成本低
|
||||
- ✅ 版本管理清晰
|
||||
- ✅ 符合行业标准
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 为什么不合并成一个容器?
|
||||
|
||||
**A**:
|
||||
- Nginx 性能远优于 Node.js 提供静态文件
|
||||
- 前后端分离便于独立升级和维护
|
||||
- 两个容器的 overhead 很小
|
||||
|
||||
### Q: 用户觉得两个容器太复杂怎么办?
|
||||
|
||||
**A**:
|
||||
- Docker Compose 已经简化了操作(一条命令)
|
||||
- 提供详细的文档和脚本
|
||||
- 可以制作一键启动脚本
|
||||
|
||||
### Q: 如何确保镜像安全性?
|
||||
|
||||
**A**:
|
||||
- 使用官方基础镜像(node:20-alpine, nginx:alpine)
|
||||
- 定期更新依赖
|
||||
- 扫描漏洞:`docker scan <image>`
|
||||
|
||||
---
|
||||
|
||||
**祝您分发顺利!** 🎉
|
||||
175
DOCKER-BUILD-GUIDE.md
Normal file
175
DOCKER-BUILD-GUIDE.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Docker 构建最终解决方案
|
||||
|
||||
## 🎯 核心改进
|
||||
|
||||
### 自动镜像源 Fallback 机制
|
||||
|
||||
现在的 Dockerfile 会自动尝试三个国内镜像源:
|
||||
|
||||
1. **阿里云镜像** (https://registry.npmmirror.com) - 首选
|
||||
2. **腾讯云镜像** (https://mirrors.cloud.tencent.com/npm/) - 备选
|
||||
3. **华为云镜像** (https://repo.huaweicloud.com/repository/npm/) - 最后备选
|
||||
|
||||
如果第一个镜像源失败,会自动切换到下一个,无需手动干预!
|
||||
|
||||
## 🚀 立即开始构建
|
||||
|
||||
### 步骤 1:清理旧缓存
|
||||
|
||||
```powershell
|
||||
docker system prune -a --volumes -f
|
||||
docker builder prune -a -f
|
||||
```
|
||||
|
||||
### 步骤 2:重新构建
|
||||
|
||||
```powershell
|
||||
# 使用 PowerShell 脚本(推荐)
|
||||
.\build-docker.ps1
|
||||
|
||||
# 或者直接使用 docker-compose
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
### 步骤 3:启动服务
|
||||
|
||||
```powershell
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 📊 构建过程说明
|
||||
|
||||
构建时您会看到类似这样的输出:
|
||||
|
||||
```
|
||||
# Backend Builder
|
||||
Trying Aliyun mirror...
|
||||
[如果阿里云成功,继续构建]
|
||||
[如果阿里云失败,显示:]
|
||||
Aliyun failed, trying Tencent Cloud...
|
||||
[如果腾讯云成功,继续构建]
|
||||
[如果腾讯云失败,显示:]
|
||||
Tencent failed, trying Huawei Cloud...
|
||||
```
|
||||
|
||||
## 🔍 故障排除
|
||||
|
||||
### 问题 1:所有镜像源都失败
|
||||
|
||||
**可能原因**:
|
||||
- 网络连接完全中断
|
||||
- 防火墙阻止了所有镜像源
|
||||
|
||||
**解决方案**:
|
||||
```powershell
|
||||
# 测试网络连接
|
||||
npm ping --registry=https://registry.npmmirror.com
|
||||
npm ping --registry=https://mirrors.cloud.tencent.com/npm/
|
||||
npm ping --registry=https://repo.huaweicloud.com/repository/npm/
|
||||
|
||||
# 检查防火墙设置
|
||||
# 确保允许访问这些域名
|
||||
```
|
||||
|
||||
### 问题 2:构建速度很慢
|
||||
|
||||
**可能原因**:
|
||||
- 正在使用较慢的镜像源
|
||||
- 首次构建需要下载大量依赖
|
||||
|
||||
**解决方案**:
|
||||
- 等待构建完成,后续构建会使用缓存
|
||||
- 或者切换到更快的镜像源(见下方)
|
||||
|
||||
### 问题 3:内存不足
|
||||
|
||||
**错误信息**:`JavaScript heap out of memory`
|
||||
|
||||
**解决方案**:
|
||||
在 Docker Desktop 中增加内存分配:
|
||||
1. Settings → Resources → Advanced
|
||||
2. 将 Memory 设置为至少 4GB
|
||||
|
||||
## 💡 手动切换镜像源(可选)
|
||||
|
||||
如果想手动指定镜像源,可以运行:
|
||||
|
||||
```powershell
|
||||
.\switch-npm-mirror.bat
|
||||
```
|
||||
|
||||
然后选择:
|
||||
- `1` - 阿里云(默认)
|
||||
- `2` - 腾讯云
|
||||
- `3` - 华为云
|
||||
|
||||
## 📝 验证构建成功
|
||||
|
||||
```powershell
|
||||
# 查看镜像列表
|
||||
docker images | findstr sillytavern-repalice
|
||||
|
||||
# 应该看到:
|
||||
# sillytavern-repalice-backend
|
||||
# sillytavern-repalice-frontend
|
||||
```
|
||||
|
||||
## 🎉 访问应用
|
||||
|
||||
构建完成后:
|
||||
|
||||
- **前端**: http://localhost:5173
|
||||
- **后端**: http://localhost:3000
|
||||
|
||||
查看日志:
|
||||
```powershell
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## ⚙️ 技术细节
|
||||
|
||||
### Dockerfile 改进
|
||||
|
||||
**之前**:
|
||||
```dockerfile
|
||||
RUN cd server && npm install
|
||||
```
|
||||
|
||||
**现在**:
|
||||
```dockerfile
|
||||
RUN cd server && \
|
||||
npm install --registry=https://registry.npmmirror.com || \
|
||||
npm install --registry=https://mirrors.cloud.tencent.com/npm/ || \
|
||||
npm install --registry=https://repo.huaweicloud.com/repository/npm/
|
||||
```
|
||||
|
||||
### 优势
|
||||
|
||||
1. ✅ **自动化**:无需手动切换镜像源
|
||||
2. ✅ **容错性**:一个失败自动尝试下一个
|
||||
3. ✅ **速度优化**:使用 `--prefer-offline`、`--no-audit`、`--no-fund`
|
||||
4. ✅ **可靠性**:三个镜像源保证至少有一个可用
|
||||
|
||||
## 🆘 仍然有问题?
|
||||
|
||||
如果以上方案都不起作用,请提供:
|
||||
|
||||
1. 完整的构建日志:
|
||||
```powershell
|
||||
docker-compose build --no-cache > build.log 2>&1
|
||||
```
|
||||
|
||||
2. 网络测试结果:
|
||||
```powershell
|
||||
.\test-npm-registry.bat
|
||||
```
|
||||
|
||||
3. 系统信息:
|
||||
```powershell
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**祝构建顺利!** 🚀
|
||||
274
PORT-CONFIG.md
Normal file
274
PORT-CONFIG.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 🔌 端口配置说明
|
||||
|
||||
## 📊 当前端口映射
|
||||
|
||||
### Docker 生产环境
|
||||
|
||||
| 服务 | 容器内部端口 | 宿主机端口 | 访问地址 |
|
||||
|------|------------|-----------|----------|
|
||||
| **Backend** (NestJS) | 3000 | 3000 | http://localhost:3000 |
|
||||
| **Frontend** (Nginx) | 80 | 23337 | http://localhost:23337 |
|
||||
|
||||
### 本地开发环境
|
||||
|
||||
| 服务 | 端口 | 访问地址 |
|
||||
|------|------|----------|
|
||||
| **Backend** (NestJS) | 3000 | http://localhost:3000 |
|
||||
| **Frontend** (Vite) | 5173 | http://localhost:5173 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 如何修改端口
|
||||
|
||||
### 修改 Docker 端口
|
||||
|
||||
编辑 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "3001:3000" # 宿主机:容器
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "23338:80" # 宿主机:容器
|
||||
```
|
||||
|
||||
然后重启:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 修改本地开发端口
|
||||
|
||||
**后端**:编辑 `server/.env` 或启动时指定
|
||||
```bash
|
||||
PORT=3001 npm run start:dev
|
||||
```
|
||||
|
||||
**前端**:编辑 `client/vite.config.ts`
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 端口冲突
|
||||
|
||||
如果端口被占用,会看到错误:
|
||||
```
|
||||
Error: port is already allocated
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 查看占用端口的进程
|
||||
```bash
|
||||
# Windows
|
||||
netstat -ano | findstr :23337
|
||||
|
||||
# Linux/Mac
|
||||
lsof -i :23337
|
||||
```
|
||||
|
||||
- 杀死进程或更换端口
|
||||
|
||||
### 2. 防火墙设置
|
||||
|
||||
确保防火墙允许访问这些端口:
|
||||
|
||||
**Windows**:
|
||||
1. 控制面板 → Windows Defender 防火墙
|
||||
2. 高级设置 → 入站规则
|
||||
3. 新建规则 → 允许端口 23337 和 3000
|
||||
|
||||
**Linux**:
|
||||
```bash
|
||||
sudo ufw allow 23337/tcp
|
||||
sudo ufw allow 3000/tcp
|
||||
```
|
||||
|
||||
**Mac**:
|
||||
系统偏好设置 → 安全性与隐私 → 防火墙 → 选项
|
||||
|
||||
### 3. CORS 配置
|
||||
|
||||
如果修改了前端端口,需要更新后端的 CORS 配置:
|
||||
|
||||
编辑 `.env`:
|
||||
```env
|
||||
FRONTEND_URL=http://localhost:23338 # 新的前端地址
|
||||
```
|
||||
|
||||
或者在 `server/src/main.ts` 中修改:
|
||||
```typescript
|
||||
app.enableCors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:23337',
|
||||
credentials: true,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 前端 API 地址
|
||||
|
||||
如果修改了后端端口,需要更新前端的 API 地址:
|
||||
|
||||
**Docker 环境**:编辑 `docker-compose.yml`
|
||||
```yaml
|
||||
frontend:
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3001/api # 新的后端地址
|
||||
```
|
||||
|
||||
**本地开发**:编辑 `client/.env`
|
||||
```env
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐的端口方案
|
||||
|
||||
### 方案 A:默认配置(推荐)
|
||||
|
||||
```yaml
|
||||
backend: 3000 → 3000
|
||||
frontend: 80 → 23337
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 避免与常见端口冲突
|
||||
- ✅ 易于记忆(23337 = "爱生生生气" 😄)
|
||||
- ✅ 远离常用端口(80, 443, 3000, 5173, 8080)
|
||||
|
||||
### 方案 B:标准端口
|
||||
|
||||
```yaml
|
||||
backend: 3000 → 3000
|
||||
frontend: 80 → 80
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 前端使用标准 HTTP 端口
|
||||
- ❌ 可能与现有 Web 服务器冲突
|
||||
|
||||
### 方案 C:开发友好
|
||||
|
||||
```yaml
|
||||
backend: 3000 → 3000
|
||||
frontend: 5173 → 5173
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 与本地开发端口一致
|
||||
- ❌ 可能与 Vite 开发服务器冲突
|
||||
|
||||
---
|
||||
|
||||
## 📝 端口选择建议
|
||||
|
||||
### 避免使用的端口
|
||||
|
||||
| 端口 | 用途 |
|
||||
|------|------|
|
||||
| 80 | HTTP 默认端口 |
|
||||
| 443 | HTTPS 默认端口 |
|
||||
| 3000 | Node.js 常用 |
|
||||
| 5173 | Vite 默认 |
|
||||
| 8080 | 代理/备用 HTTP |
|
||||
| 8443 | 备用 HTTPS |
|
||||
| 3306 | MySQL |
|
||||
| 5432 | PostgreSQL |
|
||||
| 6379 | Redis |
|
||||
| 27017 | MongoDB |
|
||||
|
||||
### 推荐的端口范围
|
||||
|
||||
- **20000-29999**:应用服务
|
||||
- **30000-39999**:临时/测试服务
|
||||
|
||||
---
|
||||
|
||||
## 🔍 检查端口占用
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
# 查看所有监听端口
|
||||
netstat -ano | findstr LISTENING
|
||||
|
||||
# 查看特定端口
|
||||
netstat -ano | findstr :23337
|
||||
|
||||
# 查看进程信息
|
||||
tasklist | findstr <PID>
|
||||
```
|
||||
|
||||
### Linux/Mac
|
||||
|
||||
```bash
|
||||
# 查看所有监听端口
|
||||
sudo lsof -i -P -n | grep LISTEN
|
||||
|
||||
# 查看特定端口
|
||||
sudo lsof -i :23337
|
||||
|
||||
# 或使用 netstat
|
||||
sudo netstat -tulpn | grep :23337
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 常见问题
|
||||
|
||||
### Q1: 为什么前端用 23337 而不是 5173?
|
||||
|
||||
**A**:
|
||||
- 避免与本地 Vite 开发服务器冲突
|
||||
- 您可以同时运行 Docker 和本地开发环境
|
||||
- 23337 是一个不太可能被占用的端口
|
||||
|
||||
### Q2: 可以前后端都用同一个端口吗?
|
||||
|
||||
**A**:
|
||||
不可以。它们是两个独立的服务,需要不同的端口。
|
||||
但可以通过 Nginx 反向代理实现统一入口(需要额外配置)。
|
||||
|
||||
### Q3: 如何在局域网访问?
|
||||
|
||||
**A**:
|
||||
1. 确保防火墙允许访问
|
||||
2. 使用宿主机的 IP 地址:
|
||||
```
|
||||
http://192.168.1.100:23337 # 前端
|
||||
http://192.168.1.100:3000 # 后端
|
||||
```
|
||||
3. 更新 CORS 配置允许局域网访问
|
||||
|
||||
### Q4: 端口修改后无法访问怎么办?
|
||||
|
||||
**A**:
|
||||
1. 检查 Docker 容器是否正常运行:`docker-compose ps`
|
||||
2. 查看日志:`docker-compose logs`
|
||||
3. 确认端口映射正确:`docker port <container-name>`
|
||||
4. 检查防火墙设置
|
||||
5. 尝试重启:`docker-compose restart`
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果遇到端口相关问题:
|
||||
|
||||
1. 查看 [USER-GUIDE.md](USER-GUIDE.md)
|
||||
2. 查看 [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
|
||||
3. 提交 Issue
|
||||
|
||||
---
|
||||
|
||||
**祝您配置顺利!** 🎉
|
||||
27
README.md
27
README.md
@@ -129,10 +129,37 @@ npm run dev
|
||||
# 或者分别启动
|
||||
npm run dev:server # 后端 http://localhost:3000
|
||||
npm run dev:client # 前端 http://localhost:5173
|
||||
|
||||
# Docker 部署后访问
|
||||
# 后端: http://localhost:3000
|
||||
# 前端: http://localhost:23337
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
#### 中国大陆用户特别说明
|
||||
|
||||
由于网络原因,在中国大陆构建 Docker 镜像可能会遇到依赖下载缓慢或卡死的问题。项目已针对此情况进行了优化:
|
||||
|
||||
1. **自动使用国内镜像源**:通过 `.npmrc` 文件配置 `https://registry.npmmirror.com` 作为 npm 镜像源
|
||||
2. **简化构建流程**:移除 BuildKit cache mount,使用更稳定的方式
|
||||
3. **提供一键构建脚本**:
|
||||
```bash
|
||||
# Windows 用户(PowerShell)
|
||||
.\build-docker.ps1
|
||||
|
||||
# Windows 用户(CMD)
|
||||
build-docker.bat
|
||||
|
||||
# Linux/Mac 用户
|
||||
chmod +x build-docker.sh
|
||||
./build-docker.sh
|
||||
```
|
||||
|
||||
4. **详细的故障排除指南**:如果构建仍然失败,请参考 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)
|
||||
|
||||
#### 标准部署流程
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
npm run docker:build
|
||||
|
||||
157
TROUBLESHOOTING.md
Normal file
157
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Docker 构建故障排除指南(中国大陆用户)
|
||||
|
||||
## 问题:Docker 构建在 `npm install` 步骤卡死
|
||||
|
||||
### 已实施的解决方案
|
||||
|
||||
1. ✅ 使用 `.npmrc` 文件配置国内镜像源
|
||||
2. ✅ 简化 Dockerfile,移除 BuildKit cache mount
|
||||
3. ✅ 添加重试机制的构建脚本
|
||||
|
||||
### 如果仍然卡死,请尝试以下步骤:
|
||||
|
||||
#### 方案 1:测试 npm 镜像源连接
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
npm ping --registry=https://registry.npmmirror.com
|
||||
|
||||
# 如果成功,会显示类似:
|
||||
# Ping success: { ... }
|
||||
```
|
||||
|
||||
或者运行测试脚本:
|
||||
```bash
|
||||
.\test-npm-registry.bat
|
||||
```
|
||||
|
||||
#### 方案 2:更换其他国内镜像源
|
||||
|
||||
如果 `registry.npmmirror.com` 不可用,可以尝试其他镜像:
|
||||
|
||||
编辑 `server/.npmrc` 和 `client/.npmrc`,将第一行改为:
|
||||
|
||||
```
|
||||
# 淘宝镜像(旧版,可能不稳定)
|
||||
registry=https://registry.npm.taobao.org
|
||||
|
||||
# 或者腾讯云镜像
|
||||
registry=https://mirrors.cloud.tencent.com/npm/
|
||||
|
||||
# 或者华为云镜像
|
||||
registry=https://repo.huaweicloud.com/repository/npm/
|
||||
```
|
||||
|
||||
#### 方案 3:手动构建(不使用 docker-compose)
|
||||
|
||||
```bash
|
||||
# 清理旧构建
|
||||
docker system prune -a --volumes
|
||||
docker builder prune -a
|
||||
|
||||
# 单独构建后端(查看详细日志)
|
||||
docker build --no-cache --progress=plain -t sillytavern-repalice-backend -f docker/backend.Dockerfile .
|
||||
|
||||
# 单独构建前端
|
||||
docker build --no-cache --progress=plain -t sillytavern-repalice-frontend -f docker/frontend.Dockerfile .
|
||||
```
|
||||
|
||||
#### 方案 4:增加 Docker 超时时间
|
||||
|
||||
在 Docker Desktop 中:
|
||||
1. 打开 Settings → Docker Engine
|
||||
2. 添加以下配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"builder": {
|
||||
"gc": {
|
||||
"enabled": true,
|
||||
"defaultKeepStorage": "20GB"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"buildkit": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案 5:使用本地 node_modules(临时方案)
|
||||
|
||||
如果 Docker 构建一直失败,可以先在本地安装依赖:
|
||||
|
||||
```bash
|
||||
# 在本地安装依赖
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
|
||||
# 然后修改 Dockerfile 使用 COPY 而不是 RUN npm install
|
||||
# (这种方式不推荐用于生产环境,但可以用于调试)
|
||||
```
|
||||
|
||||
### 常见错误及解决方案
|
||||
|
||||
#### 错误 1:ETIMEDOUT / ECONNRESET
|
||||
**原因**:网络连接超时
|
||||
**解决**:检查防火墙设置,或更换镜像源
|
||||
|
||||
#### 错误 2:ENOMEM / JavaScript heap out of memory
|
||||
**原因**:Docker 内存不足
|
||||
**解决**:在 Docker Desktop 中增加内存分配(建议至少 4GB)
|
||||
|
||||
#### 错误 3:ENOENT: no such file or directory
|
||||
**原因**:文件路径问题
|
||||
**解决**:确保 `.npmrc` 文件存在且路径正确
|
||||
|
||||
### 验证构建是否成功
|
||||
|
||||
```bash
|
||||
# 查看镜像列表
|
||||
docker images | grep sillytavern-repalice
|
||||
|
||||
# 应该看到两个镜像:
|
||||
# sillytavern-repalice-backend
|
||||
# sillytavern-repalice-frontend
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 访问应用
|
||||
# 前端: http://localhost:5173
|
||||
# 后端: http://localhost:3000
|
||||
```
|
||||
|
||||
### 获取帮助
|
||||
|
||||
如果以上方案都不起作用,请提供以下信息:
|
||||
|
||||
1. Docker 版本:`docker --version`
|
||||
2. Docker Compose 版本:`docker-compose --version`
|
||||
3. 操作系统版本
|
||||
4. 完整的错误日志:`docker-compose build --no-cache > build.log 2>&1`
|
||||
5. npm ping 测试结果
|
||||
|
||||
### 备用方案:本地开发模式
|
||||
|
||||
如果 Docker 构建实在无法完成,可以使用本地开发模式:
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 访问应用
|
||||
# 前端: http://localhost:5173
|
||||
# 后端: http://localhost:3000
|
||||
```
|
||||
|
||||
这种方式不需要 Docker,适合快速开发和测试。
|
||||
368
USER-GUIDE.md
Normal file
368
USER-GUIDE.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# 🚀 SillyTavern Repalice - 用户快速开始指南
|
||||
|
||||
欢迎使用 SillyTavern Repalice!这是一个基于 AI 的角色扮演聊天应用。
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
在开始之前,请确保您的系统已安装:
|
||||
|
||||
- ✅ [Docker Desktop](https://www.docker.com/products/docker-desktop/)(包含 Docker Compose)
|
||||
|
||||
**检查安装:**
|
||||
```bash
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速开始(3 步搞定)
|
||||
|
||||
### 步骤 1:获取项目文件
|
||||
|
||||
**方式 A:从 GitHub 克隆(推荐)**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/sillytavern-repalice.git
|
||||
cd sillytavern-repalice
|
||||
```
|
||||
|
||||
**方式 B:下载 ZIP 包**
|
||||
1. 访问项目主页
|
||||
2. 点击 "Code" → "Download ZIP"
|
||||
3. 解压到任意目录
|
||||
|
||||
### 步骤 2:配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,填入您的 API 密钥
|
||||
# Windows: notepad .env
|
||||
# Mac/Linux: nano .env
|
||||
```
|
||||
|
||||
**必填配置示例:**
|
||||
```env
|
||||
# OpenAI API 密钥(二选一)
|
||||
OPENAI_API_KEY=sk-your-openai-key-here
|
||||
|
||||
# 或者 Anthropic API 密钥
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
|
||||
# 其他配置(可保持默认)
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
> 💡 **提示**:您可以在 [OpenAI Platform](https://platform.openai.com/api-keys) 或 [Anthropic Console](https://console.anthropic.com/) 获取 API 密钥。
|
||||
|
||||
### 步骤 3:启动应用
|
||||
|
||||
```bash
|
||||
# 一键启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看启动日志(可选)
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
**等待约 1-2 分钟**,直到看到类似输出:
|
||||
```
|
||||
backend | 🚀 Server is running on http://localhost:3000
|
||||
frontend | /docker-entrypoint.sh: Configuration complete; ready for start up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 访问应用
|
||||
|
||||
启动成功后,在浏览器中打开:
|
||||
|
||||
- **🎨 前端界面**: http://localhost:23337
|
||||
- **🔧 后端 API**: http://localhost:3000
|
||||
|
||||
**首次使用**:
|
||||
1. 打开 http://localhost:23337
|
||||
2. 创建您的第一个角色卡
|
||||
3. 开始聊天!
|
||||
|
||||
---
|
||||
|
||||
## ⏹️ 停止应用
|
||||
|
||||
```bash
|
||||
# 停止所有服务
|
||||
docker-compose down
|
||||
|
||||
# 停止并删除数据(谨慎使用!)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新到最新版本
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 重新构建并启动
|
||||
docker-compose up -d --build
|
||||
|
||||
# 或者如果使用预构建镜像
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 常用命令
|
||||
|
||||
### 查看服务状态
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 查看日志
|
||||
```bash
|
||||
# 查看所有日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 只查看后端日志
|
||||
docker-compose logs -f backend
|
||||
|
||||
# 只查看前端日志
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# 查看最近 100 行
|
||||
docker-compose logs --tail=100
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker-compose restart
|
||||
|
||||
# 重启单个服务
|
||||
docker-compose restart backend
|
||||
docker-compose restart frontend
|
||||
```
|
||||
|
||||
### 进入容器(调试用)
|
||||
```bash
|
||||
# 进入后端容器
|
||||
docker-compose exec backend sh
|
||||
|
||||
# 进入前端容器
|
||||
docker-compose exec frontend sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 数据存储
|
||||
|
||||
所有数据存储在 `./data` 目录:
|
||||
|
||||
```
|
||||
data/
|
||||
├── characters/ # 角色卡
|
||||
├── chats/ # 聊天记录
|
||||
├── worldinfo/ # 世界书
|
||||
├── presets/ # 预设配置
|
||||
└── workflows/ # 工作流
|
||||
```
|
||||
|
||||
**备份数据:**
|
||||
```bash
|
||||
# 压缩备份
|
||||
tar -czf backup-$(date +%Y%m%d).tar.gz ./data
|
||||
|
||||
# Windows PowerShell
|
||||
Compress-Archive -Path .\data -DestinationPath backup-$(Get-Date -Format yyyyMMdd).zip
|
||||
```
|
||||
|
||||
**恢复数据:**
|
||||
```bash
|
||||
# 解压恢复
|
||||
tar -xzf backup-20260426.tar.gz
|
||||
|
||||
# Windows PowerShell
|
||||
Expand-Archive -Path backup-20260426.zip -DestinationPath .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常见问题
|
||||
|
||||
### ❓ 端口被占用怎么办?
|
||||
|
||||
**错误信息**:`port is already allocated`
|
||||
|
||||
**解决方案**:修改 `docker-compose.yml` 中的端口映射
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
ports:
|
||||
- "8081:3000" # 将后端改为 8081 端口
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "23338:80" # 将前端改为 23338 端口
|
||||
```
|
||||
|
||||
然后重新启动:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
访问地址变为:
|
||||
- 前端:http://localhost:23338
|
||||
- 后端:http://localhost:8081
|
||||
|
||||
---
|
||||
|
||||
### ❓ 容器启动失败怎么办?
|
||||
|
||||
**步骤 1:查看日志**
|
||||
```bash
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
**步骤 2:检查常见原因**
|
||||
- `.env` 文件是否正确配置
|
||||
- 端口是否被占用
|
||||
- Docker Desktop 是否正常运行
|
||||
|
||||
**步骤 3:重新构建**
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❓ 如何修改配置?
|
||||
|
||||
**修改环境变量:**
|
||||
1. 编辑 `.env` 文件
|
||||
2. 重启服务:`docker-compose restart`
|
||||
|
||||
**修改 Docker 配置:**
|
||||
1. 编辑 `docker-compose.yml`
|
||||
2. 重新启动:`docker-compose up -d`
|
||||
|
||||
---
|
||||
|
||||
### ❓ 数据丢失了怎么办?
|
||||
|
||||
**检查 data 目录:**
|
||||
```bash
|
||||
ls -la ./data
|
||||
```
|
||||
|
||||
**如果 data 目录为空:**
|
||||
- 可能是权限问题
|
||||
- 确保 Docker Desktop 有访问该目录的权限
|
||||
|
||||
**恢复备份:**
|
||||
```bash
|
||||
# 如果有备份,解压恢复
|
||||
tar -xzf backup-20260426.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ❓ 如何完全卸载?
|
||||
|
||||
```bash
|
||||
# 1. 停止并删除容器
|
||||
docker-compose down -v
|
||||
|
||||
# 2. 删除镜像
|
||||
docker rmi sillytavern-repalice-backend sillytavern-repalice-frontend
|
||||
|
||||
# 3. 删除数据(谨慎!)
|
||||
rm -rf ./data
|
||||
|
||||
# 4. 删除项目文件
|
||||
cd ..
|
||||
rm -rf sillytavern-repalice
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
### 文档资源
|
||||
- 📖 [项目 README](README.md)
|
||||
- 📖 [分发指南](DISTRIBUTION-GUIDE.md)
|
||||
- 📖 [故障排除](TROUBLESHOOTING.md)
|
||||
|
||||
### 社区支持
|
||||
- 🐛 [提交 Issue](https://github.com/yourusername/sillytavern-repalice/issues)
|
||||
- 💬 [讨论区](https://github.com/yourusername/sillytavern-repalice/discussions)
|
||||
|
||||
### 提供问题信息
|
||||
报告问题时,请包含:
|
||||
```bash
|
||||
# 系统信息
|
||||
docker --version
|
||||
docker-compose --version
|
||||
uname -a # Linux/Mac
|
||||
ver # Windows
|
||||
|
||||
# 日志信息
|
||||
docker-compose logs --tail=100 > logs.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 开始使用
|
||||
|
||||
现在您已经成功部署了 SillyTavern Repalice!
|
||||
|
||||
**下一步**:
|
||||
1. 访问 http://localhost:23337
|
||||
2. 创建您的第一个 AI 角色
|
||||
3. 享受聊天的乐趣!
|
||||
|
||||
**祝您使用愉快!** 🚀
|
||||
|
||||
---
|
||||
|
||||
## 📝 附录
|
||||
|
||||
### 环境变量完整说明
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 必填 |
|
||||
|--------|------|--------|------|
|
||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - | 是* |
|
||||
| `ANTHROPIC_API_KEY` | Anthropic API 密钥 | - | 是* |
|
||||
| `NODE_ENV` | 运行环境 | `production` | 否 |
|
||||
| `PORT` | 后端端口 | `3000` | 否 |
|
||||
| `DATA_DIR` | 数据目录 | `./data` | 否 |
|
||||
| `FRONTEND_URL` | 前端地址 | `http://localhost:23337` | 否 |
|
||||
| `VITE_API_URL` | API 地址 | `http://localhost:3000/api` | 否 |
|
||||
|
||||
*至少需要配置一个 LLM API 密钥
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 项目 | 最低配置 | 推荐配置 |
|
||||
|------|----------|----------|
|
||||
| CPU | 2 核心 | 4 核心 |
|
||||
| 内存 | 2 GB | 4 GB |
|
||||
| 磁盘 | 5 GB | 10 GB |
|
||||
| 操作系统 | Windows 10 / macOS 10.15 / Linux | 最新版本 |
|
||||
|
||||
### 网络要求
|
||||
|
||||
确保以下域名可访问:
|
||||
- `registry.npmmirror.com`(中国大陆 npm 镜像)
|
||||
- `hub.docker.com`(Docker Hub)
|
||||
- `api.openai.com` 或 `api.anthropic.com`(LLM API)
|
||||
37
build-docker.bat
Normal file
37
build-docker.bat
Normal file
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo Docker Build Script for China Users
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Set environment variables for China
|
||||
set DOCKER_BUILDKIT=1
|
||||
set COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
echo Step 1: Cleaning up old builds...
|
||||
docker system prune -f
|
||||
docker builder prune -f
|
||||
echo.
|
||||
|
||||
echo Step 2: Building backend service...
|
||||
docker-compose build backend
|
||||
if errorlevel 1 (
|
||||
echo Backend build failed!
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo Step 3: Building frontend service...
|
||||
docker-compose build frontend
|
||||
if errorlevel 1 (
|
||||
echo Frontend build failed!
|
||||
exit /b 1
|
||||
)
|
||||
echo.
|
||||
|
||||
echo ========================================
|
||||
echo Build completed successfully!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo You can now run: docker-compose up -d
|
||||
59
build-docker.ps1
Normal file
59
build-docker.ps1
Normal file
@@ -0,0 +1,59 @@
|
||||
# Docker Build Script for China Users (PowerShell)
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host "Docker Build Script for China Users" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Set environment variables for China
|
||||
$env:DOCKER_BUILDKIT = 1
|
||||
$env:COMPOSE_DOCKER_CLI_BUILD = 1
|
||||
$env:BUILDKIT_PROGRESS = "plain"
|
||||
|
||||
Write-Host "Step 1: Cleaning up old builds..." -ForegroundColor Yellow
|
||||
docker system prune -f
|
||||
docker builder prune -f
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Step 2: Building backend service (this may take a while)..." -ForegroundColor Yellow
|
||||
$backendStart = Get-Date
|
||||
docker-compose build --no-cache backend
|
||||
$backendExitCode = $LASTEXITCODE
|
||||
$backendEnd = Get-Date
|
||||
$backendDuration = ($backendEnd - $backendStart).TotalMinutes
|
||||
Write-Host "Backend build took: $backendDuration minutes" -ForegroundColor Gray
|
||||
|
||||
if ($backendExitCode -ne 0) {
|
||||
Write-Host "Backend build failed!" -ForegroundColor Red
|
||||
Write-Host "Trying again with verbose output..." -ForegroundColor Yellow
|
||||
docker build --no-cache -t sillytavern-repalice-backend -f docker/backend.Dockerfile .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Backend build failed again. Please check the error messages above." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "Step 3: Building frontend service (this may take a while)..." -ForegroundColor Yellow
|
||||
$frontendStart = Get-Date
|
||||
docker-compose build --no-cache frontend
|
||||
$frontendExitCode = $LASTEXITCODE
|
||||
$frontendEnd = Get-Date
|
||||
$frontendDuration = ($frontendEnd - $frontendStart).TotalMinutes
|
||||
Write-Host "Frontend build took: $frontendDuration minutes" -ForegroundColor Gray
|
||||
|
||||
if ($frontendExitCode -ne 0) {
|
||||
Write-Host "Frontend build failed!" -ForegroundColor Red
|
||||
Write-Host "Trying again with verbose output..." -ForegroundColor Yellow
|
||||
docker build --no-cache -t sillytavern-repalice-frontend -f docker/frontend.Dockerfile .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Frontend build failed again. Please check the error messages above." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "Build completed successfully!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "You can now run: docker-compose up -d" -ForegroundColor White
|
||||
38
build-docker.sh
Normal file
38
build-docker.sh
Normal file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker Build Script for China Users (Linux/Mac)
|
||||
echo "========================================"
|
||||
echo "Docker Build Script for China Users"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Set environment variables for China
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
echo "Step 1: Cleaning up old builds..."
|
||||
docker system prune -f
|
||||
docker builder prune -f
|
||||
echo ""
|
||||
|
||||
echo "Step 2: Building backend service..."
|
||||
docker-compose build backend
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Backend build failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "Step 3: Building frontend service..."
|
||||
docker-compose build frontend
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Frontend build failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "========================================"
|
||||
echo "Build completed successfully!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "You can now run: docker-compose up -d"
|
||||
7
client/.npmrc
Normal file
7
client/.npmrc
Normal file
@@ -0,0 +1,7 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
fetch-timeout=600000
|
||||
maxsockets=10
|
||||
prefer-offline=true
|
||||
audit=false
|
||||
fund=false
|
||||
loglevel=error
|
||||
7
client/.npmrc.tencent
Normal file
7
client/.npmrc.tencent
Normal file
@@ -0,0 +1,7 @@
|
||||
registry=https://mirrors.cloud.tencent.com/npm/
|
||||
fetch-timeout=600000
|
||||
maxsockets=10
|
||||
prefer-offline=true
|
||||
audit=false
|
||||
fund=false
|
||||
loglevel=error
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/src/assets/logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SillyTavern Repalice</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
@@ -25,6 +25,6 @@
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27"
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Root component
|
||||
import { onMounted } from 'vue';
|
||||
import { useTheme } from '@/composables/useTheme';
|
||||
|
||||
// Initialize theme
|
||||
const { theme } = useTheme();
|
||||
|
||||
onMounted(() => {
|
||||
// Apply initial theme
|
||||
document.documentElement.setAttribute('data-theme', theme.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<div class="center-panel">
|
||||
<p>Center Panel - Chat Interface</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// CenterPanel component placeholder
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center-panel {
|
||||
flex: 1;
|
||||
background-color: var(--color-bg-primary);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
1
client/src/components/CenterPanel/DEPRECATED.md
Normal file
1
client/src/components/CenterPanel/DEPRECATED.md
Normal file
@@ -0,0 +1 @@
|
||||
# This directory is deprecated. Files have been moved to features/ module structure.
|
||||
1
client/src/components/LeftPanel/DEPRECATED.md
Normal file
1
client/src/components/LeftPanel/DEPRECATED.md
Normal file
@@ -0,0 +1 @@
|
||||
# This directory is deprecated. Files have been moved to features/ module structure.
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="left-panel">
|
||||
<p>Left Panel - Character List & Chat History</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// LeftPanel component placeholder
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.left-panel {
|
||||
width: 300px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
1
client/src/components/RightPanel/DEPRECATED.md
Normal file
1
client/src/components/RightPanel/DEPRECATED.md
Normal file
@@ -0,0 +1 @@
|
||||
# This directory is deprecated. Files have been moved to features/ module structure.
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="right-panel">
|
||||
<p>Right Panel - Character Detail & Workflow Editor</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// RightPanel component placeholder
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.right-panel {
|
||||
width: 400px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-left: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
122
client/src/components/common/DualTabSwitcher/DualTabSwitcher.vue
Normal file
122
client/src/components/common/DualTabSwitcher/DualTabSwitcher.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="dual-tab-container">
|
||||
<div class="tab-header">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-button"
|
||||
:class="{ active: modelValue.includes(tab.id) }"
|
||||
@click="handleTabClick(tab.id)"
|
||||
>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tabs: Tab[];
|
||||
modelValue: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void;
|
||||
}>();
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
const isSelected = props.modelValue.includes(tabId);
|
||||
|
||||
if (isSelected) {
|
||||
// 如果已选中,取消选择
|
||||
emit('update:modelValue', props.modelValue.filter(id => id !== tabId));
|
||||
} else {
|
||||
// 如果未选中,添加到选择列表
|
||||
const newSelection = [...props.modelValue, tabId];
|
||||
|
||||
// 限制最多选择2个
|
||||
if (newSelection.length > 2) {
|
||||
// 移除最早选中的(第一个)
|
||||
newSelection.shift();
|
||||
}
|
||||
|
||||
emit('update:modelValue', newSelection);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dual-tab-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-md) var(--spacing-md) 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tab-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scaleX(0);
|
||||
width: 60%;
|
||||
height: 2px;
|
||||
background: var(--gradient-primary);
|
||||
transition: transform var(--transition-normal);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tab-button.active::after {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
102
client/src/components/common/TabSwitcher/TabSwitcher.vue
Normal file
102
client/src/components/common/TabSwitcher/TabSwitcher.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="tab-container">
|
||||
<div class="tab-header">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-button"
|
||||
:class="{ active: modelValue === tab.id }"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
tabs: Tab[];
|
||||
modelValue: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-md) var(--spacing-md) 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.tab-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scaleX(0);
|
||||
width: 60%;
|
||||
height: 2px;
|
||||
background: var(--gradient-primary);
|
||||
transition: transform var(--transition-normal);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.tab-button.active::after {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
2
client/src/composables/index.ts
Normal file
2
client/src/composables/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useTheme } from './useTheme';
|
||||
export { useLocalStorage } from './useLocalStorage';
|
||||
14
client/src/composables/useLocalStorage.ts
Normal file
14
client/src/composables/useLocalStorage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
// Get value from localStorage or use initial value
|
||||
const storedValue = localStorage.getItem(key);
|
||||
const value = ref<T>(storedValue ? JSON.parse(storedValue) : initialValue);
|
||||
|
||||
// Watch for changes and update localStorage
|
||||
watch(value, (newValue) => {
|
||||
localStorage.setItem(key, JSON.stringify(newValue));
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/useAppStore';
|
||||
|
||||
export function useTheme() {
|
||||
const appStore = useAppStore();
|
||||
|
||||
function toggleTheme() {
|
||||
appStore.toggleTheme();
|
||||
}
|
||||
|
||||
// Watch theme changes and apply to document
|
||||
watch(
|
||||
() => appStore.theme,
|
||||
(newTheme) => {
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
theme: appStore.theme,
|
||||
toggleTheme,
|
||||
};
|
||||
}
|
||||
|
||||
7
client/src/env.d.ts
vendored
Normal file
7
client/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
51
client/src/layouts/CenterPanel/CenterPanel.vue
Normal file
51
client/src/layouts/CenterPanel/CenterPanel.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="center-panel">
|
||||
<MessageList
|
||||
:render-markdown="chatInputRef?.renderMarkdown"
|
||||
:render-html="chatInputRef?.renderHTML"
|
||||
/>
|
||||
<ChatInput ref="chatInputRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from 'vue';
|
||||
import MessageList from './features/MessageList/MessageList.vue';
|
||||
import ChatInput from './features/ChatInput/ChatInput.vue';
|
||||
|
||||
// Use shallowRef to prevent Vue from unwrapping the nested refs
|
||||
const chatInputRef = shallowRef<InstanceType<typeof ChatInput> | null>(null);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.center-panel {
|
||||
flex: 0 0 60%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--color-bg-primary);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle gradient background for visual interest */
|
||||
.center-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(109, 140, 255, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(109, 140, 255, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--color-bg-primary) 0%, var(--color-bg-subtle) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.center-panel > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
3
client/src/layouts/CenterPanel/center-panel.css
Normal file
3
client/src/layouts/CenterPanel/center-panel.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.center-panel {
|
||||
/* Center panel base styles */
|
||||
}
|
||||
1
client/src/layouts/CenterPanel/features/.gitkeep
Normal file
1
client/src/layouts/CenterPanel/features/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# CenterPanel Features
|
||||
@@ -0,0 +1 @@
|
||||
# ChatInput Feature
|
||||
322
client/src/layouts/CenterPanel/features/ChatInput/ChatInput.vue
Normal file
322
client/src/layouts/CenterPanel/features/ChatInput/ChatInput.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<div class="input-container">
|
||||
<div class="options-wrapper">
|
||||
<button
|
||||
class="options-toggle"
|
||||
:class="{ active: showOptions }"
|
||||
@click="showOptions = !showOptions"
|
||||
title="Toggle Options"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<transition name="slide-down">
|
||||
<div v-if="showOptions" class="input-options">
|
||||
<label class="option-checkbox">
|
||||
<input type="checkbox" v-model="renderHTML" />
|
||||
<span class="checkmark"></span>
|
||||
<span class="option-label">HTML渲染</span>
|
||||
</label>
|
||||
<label class="option-checkbox">
|
||||
<input type="checkbox" v-model="renderMarkdown" />
|
||||
<span class="checkmark"></span>
|
||||
<span class="option-label">MD渲染</span>
|
||||
</label>
|
||||
<label class="option-checkbox">
|
||||
<input type="checkbox" v-model="enableDynamicTables" />
|
||||
<span class="checkmark"></span>
|
||||
<span class="option-label">动态表格</span>
|
||||
</label>
|
||||
<label class="option-checkbox">
|
||||
<input type="checkbox" v-model="enableDrawing" />
|
||||
<span class="checkmark"></span>
|
||||
<span class="option-label">绘图</span>
|
||||
</label>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Type your message..."
|
||||
class="message-input"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button class="send-btn" title="Send Message">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useLocalStorage } from '@/composables/useLocalStorage';
|
||||
|
||||
const showOptions = ref(false);
|
||||
|
||||
// Render options with localStorage persistence
|
||||
const renderMarkdown = useLocalStorage<boolean>('message-render-markdown', true);
|
||||
const renderHTML = useLocalStorage<boolean>('message-render-html', true);
|
||||
const enableDynamicTables = useLocalStorage<boolean>('message-render-tables', false);
|
||||
const enableDrawing = useLocalStorage<boolean>('message-render-drawing', false);
|
||||
|
||||
// Debug: Watch for changes
|
||||
watch(renderMarkdown, (newVal) => {
|
||||
console.log('[ChatInput] renderMarkdown changed to:', newVal);
|
||||
});
|
||||
|
||||
watch(renderHTML, (newVal) => {
|
||||
console.log('[ChatInput] renderHTML changed to:', newVal);
|
||||
});
|
||||
|
||||
// Expose render options for parent components to use
|
||||
defineExpose({
|
||||
renderMarkdown,
|
||||
renderHTML,
|
||||
enableDynamicTables,
|
||||
enableDrawing
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-input {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg), 0 -4px 12px rgba(0, 0, 0, 0.03);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.options-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: var(--shadow-inner);
|
||||
}
|
||||
|
||||
.options-toggle:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.options-toggle.active {
|
||||
background-color: var(--color-accent-light);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.options-toggle svg {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.options-toggle.active svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.input-options {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--spacing-sm));
|
||||
left: 0;
|
||||
background-color: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-sm);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.option-checkbox input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-checkbox:hover .checkmark {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.option-checkbox input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Slide down animation */
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.slide-down-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.slide-down-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.message-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 160px;
|
||||
transition: all var(--transition-normal);
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: var(--shadow-inner);
|
||||
}
|
||||
|
||||
.message-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light), var(--shadow-inner);
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.message-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
flex-shrink: 0;
|
||||
box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 127, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.send-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.send-btn:hover::before {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-xl), 0 0 0 2px rgba(91, 127, 255, 0.2);
|
||||
}
|
||||
|
||||
.send-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.send-btn svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.send-btn:hover svg {
|
||||
transform: scale(1.1) rotate(-5deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
.chat-input {
|
||||
/* Chat input styles */
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useChatInput() {
|
||||
const inputText = ref('');
|
||||
|
||||
function sendMessage() {
|
||||
if (inputText.value.trim()) {
|
||||
console.log('Sending:', inputText.value);
|
||||
inputText.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputText,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# MessageList Feature
|
||||
@@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<div class="message-list">
|
||||
<div class="messages-container">
|
||||
<div class="message user-message">
|
||||
<div class="message-avatar"></div>
|
||||
<div class="message-content">
|
||||
<p v-html="renderMessageContent(userMessage)"></p>
|
||||
<div class="message-actions">
|
||||
<button class="action-btn" title="Edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Delete">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="message-time">10:30 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message assistant-message">
|
||||
<div class="message-avatar"></div>
|
||||
<div class="message-content">
|
||||
<p v-html="renderMessageContent(assistantMessage)"></p>
|
||||
<div class="message-actions">
|
||||
<button class="action-btn" title="Edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Delete">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" title="Copy">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="message-time">10:31 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message user-message">
|
||||
<div class="message-avatar"></div>
|
||||
<div class="message-content">
|
||||
<p>Another message to show the conversation flow.</p>
|
||||
<span class="message-time">10:32 AM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
// Props from parent component - Vue automatically unwraps refs in templates
|
||||
const props = defineProps<{
|
||||
renderMarkdown?: boolean;
|
||||
renderHTML?: boolean;
|
||||
}>();
|
||||
|
||||
// Use props or default to true
|
||||
const renderMarkdown = computed(() => {
|
||||
const value = props.renderMarkdown ?? true;
|
||||
console.log('[MessageList] renderMarkdown:', value);
|
||||
return value;
|
||||
});
|
||||
|
||||
const renderHTML = computed(() => {
|
||||
const value = props.renderHTML ?? true;
|
||||
console.log('[MessageList] renderHTML:', value);
|
||||
return value;
|
||||
});
|
||||
|
||||
// Watch for changes
|
||||
watch(renderMarkdown, (newVal) => {
|
||||
console.log('[MessageList] renderMarkdown changed to:', newVal);
|
||||
});
|
||||
|
||||
watch(renderHTML, (newVal) => {
|
||||
console.log('[MessageList] renderHTML changed to:', newVal);
|
||||
});
|
||||
|
||||
// Simple Markdown parser for testing (no external dependencies)
|
||||
function simpleMarkdownParse(text: string): string {
|
||||
let html = text;
|
||||
|
||||
// If HTML rendering is disabled, escape HTML first
|
||||
if (!renderHTML.value) {
|
||||
html = escapeHtml(html);
|
||||
}
|
||||
|
||||
// Code blocks (must be processed before other rules to avoid conflicts)
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
|
||||
const escapedCode = renderHTML.value ? code.trim() : escapeHtml(code.trim());
|
||||
return `<pre><code class="language-${lang || 'text'}">${escapedCode}</code></pre>`;
|
||||
});
|
||||
|
||||
// Inline code (must be processed before other inline rules)
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.+)$/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold and Italic (order matters: *** before ** before *)
|
||||
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||
|
||||
// Horizontal rule
|
||||
html = html.replace(/^---$/gim, '<hr>');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>');
|
||||
html = html.replace(/^> (.+)$/gim, '<blockquote>$1</blockquote>');
|
||||
|
||||
// Unordered lists
|
||||
const ulPattern = /^(?:[-*+]\s+.+\n?)+/gm;
|
||||
html = html.replace(ulPattern, (match) => {
|
||||
const items = match.trim().split('\n').map(line => {
|
||||
const content = line.replace(/^[-*+]\s+/, '');
|
||||
return `<li>${content}</li>`;
|
||||
}).join('');
|
||||
return `<ul>${items}</ul>`;
|
||||
});
|
||||
|
||||
// Ordered lists
|
||||
const olPattern = /^(?:\d+\.\s+.+\n?)+/gm;
|
||||
html = html.replace(olPattern, (match) => {
|
||||
const items = match.trim().split('\n').map(line => {
|
||||
const content = line.replace(/^\d+\.\s+/, '');
|
||||
return `<li>${content}</li>`;
|
||||
}).join('');
|
||||
return `<ol>${items}</ol>`;
|
||||
});
|
||||
|
||||
// Line breaks (convert newlines to <br>, but not inside pre tags)
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Escape HTML special characters
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderMessageContent(content: string): string {
|
||||
console.log('[renderMessageContent] Input:', content.substring(0, 50));
|
||||
console.log('[renderMessageContent] renderMarkdown:', renderMarkdown.value, 'renderHTML:', renderHTML.value);
|
||||
|
||||
// If neither markdown nor HTML rendering is enabled, escape and return plain text
|
||||
if (!renderMarkdown.value && !renderHTML.value) {
|
||||
const result = escapeHtml(content);
|
||||
console.log('[renderMessageContent] Both disabled, escaped:', result.substring(0, 50));
|
||||
return result;
|
||||
}
|
||||
|
||||
// If markdown rendering is enabled, parse it (HTML rendering controls whether HTML tags are escaped)
|
||||
if (renderMarkdown.value) {
|
||||
const result = simpleMarkdownParse(content);
|
||||
console.log('[renderMessageContent] Markdown parsed, result length:', result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// If only HTML rendering is enabled (no markdown), return as-is
|
||||
if (renderHTML.value) {
|
||||
console.log('[renderMessageContent] HTML only, returning as-is');
|
||||
return content;
|
||||
}
|
||||
|
||||
// HTML rendering disabled, no markdown - escape everything
|
||||
const result = escapeHtml(content);
|
||||
console.log('[renderMessageContent] HTML disabled, escaped:', result.substring(0, 50));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sample messages for testing - using LLM provided examples
|
||||
const userMessage = '你好!这是一个**测试消息**,包含以下功能:\n\n1. **粗体文本**\n2. *斜体文本*\n3. `行内代码`\n4. [链接示例](https://example.com)';
|
||||
|
||||
const assistantMessage = [
|
||||
'## Markdown 渲染测试',
|
||||
'',
|
||||
'这是一个功能丰富的回复,展示各种 Markdown 语法:',
|
||||
'',
|
||||
'### 列表示例',
|
||||
'- 无序列表项 1',
|
||||
'- 无序列表项 2',
|
||||
' - 嵌套列表项',
|
||||
'- 无序列表项 3',
|
||||
'',
|
||||
'1. 有序列表项 1',
|
||||
'2. 有序列表项 2',
|
||||
'3. 有序列表项 3',
|
||||
'',
|
||||
'### 引用示例',
|
||||
'> 这是一段引用文本',
|
||||
'> 可以有多行内容',
|
||||
'',
|
||||
'### 代码示例',
|
||||
'行内代码:`const message = "Hello";`',
|
||||
'',
|
||||
'代码块:',
|
||||
'```javascript',
|
||||
'function greet(name) {',
|
||||
' return `Hello, ${name}!`;',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'### HTML 标签测试',
|
||||
'<div style="color: #5b7fff; font-weight: bold;">这是自定义样式的 HTML 文本</div>',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'**粗体**、*斜体*、***粗斜体*** 都可以正常显示!'
|
||||
].join('\n');
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
max-width: 75%;
|
||||
animation: fadeIn 0.4s var(--transition-smooth);
|
||||
}
|
||||
|
||||
.user-message {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #c5d0ff, #dce4ff);
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent);
|
||||
}
|
||||
|
||||
.user-message .message-avatar {
|
||||
background: var(--gradient-primary);
|
||||
box-shadow: var(--shadow-md), 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.6;
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.01em;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Markdown rendered content styles */
|
||||
.message-content p :deep(strong),
|
||||
.message-content p :deep(b) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-content p :deep(em),
|
||||
.message-content p :deep(i) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-content p :deep(code) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.user-message .message-content p :deep(code) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.message-content p :deep(pre) {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.user-message .message-content p :deep(pre) {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.message-content p :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-content p :deep(ul),
|
||||
.message-content p :deep(ol) {
|
||||
margin: var(--spacing-sm) 0;
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.message-content p :deep(li) {
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.message-content p :deep(blockquote) {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
padding-left: var(--spacing-md);
|
||||
margin: var(--spacing-sm) 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-content p :deep(h1),
|
||||
.message-content p :deep(h2),
|
||||
.message-content p :deep(h3),
|
||||
.message-content p :deep(h4),
|
||||
.message-content p :deep(h5),
|
||||
.message-content p :deep(h6) {
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-content p :deep(h1) { font-size: 1.5em; }
|
||||
.message-content p :deep(h2) { font-size: 1.3em; }
|
||||
.message-content p :deep(h3) { font-size: 1.1em; }
|
||||
|
||||
.message-content p :deep(a) {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.message-content p :deep(a:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.message-content:hover .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.message-actions .action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.message-actions .action-btn:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.user-message .message-content p {
|
||||
background: var(--gradient-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 127, 255, 0.1);
|
||||
}
|
||||
|
||||
.assistant-message .message-content p {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 var(--spacing-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.user-message .message-time {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.message-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
.message-list {
|
||||
/* Message list styles */
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useMessageList() {
|
||||
const messages = ref<Array<{ id: string; role: string; content: string }>>([]);
|
||||
|
||||
function addMessage(message: { id: string; role: string; content: string }) {
|
||||
messages.value.push(message);
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
addMessage,
|
||||
};
|
||||
}
|
||||
1
client/src/layouts/CenterPanel/stores/.gitkeep
Normal file
1
client/src/layouts/CenterPanel/stores/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# CenterPanel Stores
|
||||
17
client/src/layouts/CenterPanel/stores/useCenterPanelStore.ts
Normal file
17
client/src/layouts/CenterPanel/stores/useCenterPanelStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useCenterPanelStore = defineStore('centerPanel', () => {
|
||||
// State
|
||||
const isTyping = ref(false);
|
||||
|
||||
// Actions
|
||||
function setTyping(typing: boolean) {
|
||||
isTyping.value = typing;
|
||||
}
|
||||
|
||||
return {
|
||||
isTyping,
|
||||
setTyping,
|
||||
};
|
||||
});
|
||||
99
client/src/layouts/LeftPanel/LeftPanel.vue
Normal file
99
client/src/layouts/LeftPanel/LeftPanel.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="left-panel">
|
||||
<TabSwitcher v-model="activeTab" :tabs="tabs">
|
||||
<!-- Placeholder components for each tab -->
|
||||
<div v-if="activeTab === 'world'" class="tab-placeholder">
|
||||
<h3>World Info</h3>
|
||||
<p>世界书管理页面(待实现)</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'api'" class="tab-placeholder">
|
||||
<h3>API Configuration</h3>
|
||||
<p>API 配置页面(待实现)</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'presets'" class="tab-placeholder">
|
||||
<h3>Presets</h3>
|
||||
<p>预设管理页面(待实现)</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'gallery'" class="tab-placeholder">
|
||||
<h3>Gallery</h3>
|
||||
<p>画廊页面(待实现)</p>
|
||||
</div>
|
||||
<div v-if="activeTab === 'characters'" class="tab-placeholder">
|
||||
<h3>AI Character Cards</h3>
|
||||
<p>AI 角色卡管理页面(待实现)</p>
|
||||
</div>
|
||||
</TabSwitcher>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import TabSwitcher from '@/components/common/TabSwitcher/TabSwitcher.vue';
|
||||
|
||||
const activeTab = ref('world');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'world', label: 'World' },
|
||||
{ id: 'api', label: 'API' },
|
||||
{ id: 'presets', label: 'Presets' },
|
||||
{ id: 'gallery', label: 'Gallery' },
|
||||
{ id: 'characters', label: 'Characters' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.left-panel {
|
||||
flex: 0 0 20%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tab-placeholder h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-placeholder p {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.left-panel::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.left-panel::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.left-panel::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.left-panel::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
1
client/src/layouts/LeftPanel/features/.gitkeep
Normal file
1
client/src/layouts/LeftPanel/features/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# LeftPanel Features
|
||||
@@ -0,0 +1 @@
|
||||
# CharacterList Feature
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="character-list">
|
||||
<div class="section-header">
|
||||
<h3>Characters</h3>
|
||||
<button class="add-btn" title="Add Character">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="character-items">
|
||||
<div class="character-item">
|
||||
<div class="character-avatar"></div>
|
||||
<div class="character-info">
|
||||
<h4>Character Name</h4>
|
||||
<p>Brief description...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="character-item">
|
||||
<div class="character-avatar"></div>
|
||||
<div class="character-info">
|
||||
<h4>Another Character</h4>
|
||||
<p>Another brief description...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// CharacterList component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-md);
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.add-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.character-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--gradient-primary);
|
||||
transform: scaleY(0);
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.character-item:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.character-item:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, #c5d0ff, #dce4ff);
|
||||
flex-shrink: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: calc(var(--radius-md) - 2px);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.character-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.character-info h4 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 2px 0;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.character-info p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
.character-list {
|
||||
/* Character list styles */
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useCharacterList() {
|
||||
const searchQuery = ref('');
|
||||
|
||||
function filterCharacters(query: string) {
|
||||
searchQuery.value = query;
|
||||
}
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
filterCharacters,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# ChatHistory Feature
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="chat-history">
|
||||
<div class="section-header">
|
||||
<h3>Chat History</h3>
|
||||
</div>
|
||||
<div class="history-items">
|
||||
<div class="history-item active">
|
||||
<div class="history-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="history-info">
|
||||
<h4>Current Conversation</h4>
|
||||
<p>Last message preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="history-item">
|
||||
<div class="history-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="history-info">
|
||||
<h4>Previous Chat</h4>
|
||||
<p>Another conversation...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ChatHistory component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-history {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-md);
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.history-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--gradient-primary);
|
||||
transform: scaleY(0);
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.history-item:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
background-color: var(--color-accent-light);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.history-item.active::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.history-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.history-item.active .history-icon {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md), 0 0 0 2px var(--color-accent-light);
|
||||
}
|
||||
|
||||
.history-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-info h4 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 2px 0;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item.active .history-info h4 {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.history-info p {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
.chat-history {
|
||||
/* Chat history styles */
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useChatHistory() {
|
||||
const histories = ref<Array<{ id: string; title: string }>>([]);
|
||||
|
||||
function loadHistory() {
|
||||
// TODO: Load chat history
|
||||
console.log('Loading chat history...');
|
||||
}
|
||||
|
||||
return {
|
||||
histories,
|
||||
loadHistory,
|
||||
};
|
||||
}
|
||||
3
client/src/layouts/LeftPanel/left-panel.css
Normal file
3
client/src/layouts/LeftPanel/left-panel.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.left-panel {
|
||||
/* Left panel base styles */
|
||||
}
|
||||
1
client/src/layouts/LeftPanel/stores/.gitkeep
Normal file
1
client/src/layouts/LeftPanel/stores/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# LeftPanel Stores
|
||||
17
client/src/layouts/LeftPanel/stores/useLeftPanelStore.ts
Normal file
17
client/src/layouts/LeftPanel/stores/useLeftPanelStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useLeftPanelStore = defineStore('leftPanel', () => {
|
||||
// State
|
||||
const activeTab = ref<'characters' | 'history'>('characters');
|
||||
|
||||
// Actions
|
||||
function setActiveTab(tab: 'characters' | 'history') {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
};
|
||||
});
|
||||
@@ -10,10 +10,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TopBar from '@/components/TopBar/TopBar.vue';
|
||||
import LeftPanel from '@/components/LeftPanel/LeftPanel.vue';
|
||||
import CenterPanel from '@/components/CenterPanel/CenterPanel.vue';
|
||||
import RightPanel from '@/components/RightPanel/RightPanel.vue';
|
||||
import TopBar from '../TopBar/TopBar.vue';
|
||||
import LeftPanel from '../LeftPanel/LeftPanel.vue';
|
||||
import CenterPanel from '../CenterPanel/CenterPanel.vue';
|
||||
import RightPanel from '../RightPanel/RightPanel.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -24,11 +24,25 @@ import RightPanel from '@/components/RightPanel/RightPanel.vue';
|
||||
height: 100vh;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* Ensure panels maintain their proportions */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
.main-layout,
|
||||
.main-layout * {
|
||||
transition: background-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
143
client/src/layouts/RightPanel/RightPanel.vue
Normal file
143
client/src/layouts/RightPanel/RightPanel.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="right-panel">
|
||||
<DualTabSwitcher v-model="selectedTabs" :tabs="tabs">
|
||||
<div class="panel-content">
|
||||
<div
|
||||
v-if="selectedTabs.includes('rag')"
|
||||
class="panel-section"
|
||||
:class="{ 'has-divider': selectedTabs.length === 2 }"
|
||||
>
|
||||
<div class="tab-placeholder">
|
||||
<h3>RAG Information</h3>
|
||||
<p>RAG 信息页面(待实现)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabs.includes('worldentries')"
|
||||
class="panel-section"
|
||||
:class="{ 'has-divider': selectedTabs.length === 2 && selectedTabs.indexOf('worldentries') < selectedTabs.length - 1 }"
|
||||
>
|
||||
<div class="tab-placeholder">
|
||||
<h3>World Info Entries</h3>
|
||||
<p>世界书条目页面(待实现)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabs.includes('dynamictable')"
|
||||
class="panel-section"
|
||||
:class="{ 'has-divider': selectedTabs.length === 2 && selectedTabs.indexOf('dynamictable') < selectedTabs.length - 1 }"
|
||||
>
|
||||
<div class="tab-placeholder">
|
||||
<h3>Dynamic Table</h3>
|
||||
<p>动态表格页面(待实现)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabs.includes('dice')"
|
||||
class="panel-section"
|
||||
>
|
||||
<div class="tab-placeholder">
|
||||
<h3>Dice Roller</h3>
|
||||
<p>骰子页面(待实现)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DualTabSwitcher>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import DualTabSwitcher from '@/components/common/DualTabSwitcher/DualTabSwitcher.vue';
|
||||
|
||||
// 默认选中两个页面
|
||||
const selectedTabs = ref(['rag', 'worldentries']);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'rag', label: 'RAG' },
|
||||
{ id: 'worldentries', label: 'World Entries' },
|
||||
{ id: 'dynamictable', label: 'Dynamic Table' },
|
||||
{ id: 'dice', label: 'Dice' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.right-panel {
|
||||
flex: 0 0 20%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-left: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-xs);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 当有两个页面时,第一个页面添加底部分隔线 */
|
||||
.panel-section.has-divider {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* 当只有一个页面选中时,占据全部空间 */
|
||||
.panel-section:only-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab-placeholder {
|
||||
padding: var(--spacing-lg);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tab-placeholder h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-placeholder p {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.right-panel::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.right-panel::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.right-panel::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.right-panel::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
1
client/src/layouts/RightPanel/features/.gitkeep
Normal file
1
client/src/layouts/RightPanel/features/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# RightPanel Features
|
||||
@@ -0,0 +1 @@
|
||||
# CharacterDetail Feature
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="character-detail">
|
||||
<div class="detail-header">
|
||||
<h3>Character Details</h3>
|
||||
<button class="edit-btn" @click="toggleEdit" title="Edit Character">
|
||||
<svg v-if="!isEditing" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 11 12 14 22 4"></polyline>
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">
|
||||
<div class="avatar-section">
|
||||
<div class="character-avatar"></div>
|
||||
<div class="avatar-actions">
|
||||
<button class="action-btn" title="Change Avatar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="form-group">
|
||||
<label for="char-name">Name</label>
|
||||
<input
|
||||
id="char-name"
|
||||
type="text"
|
||||
value="Character Name"
|
||||
:disabled="!isEditing"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="char-desc">Description</label>
|
||||
<textarea
|
||||
id="char-desc"
|
||||
value="A brief description of the character..."
|
||||
:disabled="!isEditing"
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="char-personality">Personality</label>
|
||||
<textarea
|
||||
id="char-personality"
|
||||
value="Friendly, curious, and helpful..."
|
||||
:disabled="!isEditing"
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCharacterDetail } from './useCharacterDetail';
|
||||
|
||||
const { isEditing, toggleEdit } = useCharacterDetail();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-detail {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.detail-header h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px) rotate(5deg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.edit-btn:active {
|
||||
transform: translateY(0) rotate(0);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.character-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(135deg, #c5d0ff, #dce4ff);
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(91, 127, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.character-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: calc(var(--radius-xl) - 3px);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), transparent);
|
||||
}
|
||||
|
||||
.avatar-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--transition-normal);
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.form-input:disabled,
|
||||
.form-textarea:disabled {
|
||||
background-color: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for webkit browsers */
|
||||
.character-detail::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.character-detail::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.character-detail::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.character-detail::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
.character-detail {
|
||||
/* Character detail styles */
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useCharacterDetail() {
|
||||
const isEditing = ref(false);
|
||||
|
||||
function toggleEdit() {
|
||||
isEditing.value = !isEditing.value;
|
||||
}
|
||||
|
||||
return {
|
||||
isEditing,
|
||||
toggleEdit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# WorkflowEditor Feature
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="workflow-editor">
|
||||
<div class="section-header">
|
||||
<h3>Workflow Editor</h3>
|
||||
<button class="add-btn" title="Add Workflow">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="workflow-items">
|
||||
<div class="workflow-item">
|
||||
<div class="workflow-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6"></polyline>
|
||||
<polyline points="8 6 2 12 8 18"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="workflow-info">
|
||||
<h4>Sample Workflow</h4>
|
||||
<p>A basic AI workflow...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workflow-item">
|
||||
<div class="workflow-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6"></polyline>
|
||||
<polyline points="8 6 2 12 8 18"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="workflow-info">
|
||||
<h4>Another Workflow</h4>
|
||||
<p>More complex workflow...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// WorkflowEditor component
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.add-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.workflow-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workflow-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--gradient-primary);
|
||||
transform: scaleY(0);
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.workflow-item:hover {
|
||||
background-color: var(--color-bg-elevated);
|
||||
border-color: var(--color-border-focus);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.workflow-item:hover::before {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
.workflow-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.workflow-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-info h4 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 2px 0;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-info p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useWorkflowEditor() {
|
||||
const workflows = ref<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
function addWorkflow(name: string) {
|
||||
workflows.value.push({
|
||||
id: Date.now().toString(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
workflows,
|
||||
addWorkflow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.workflow-editor {
|
||||
/* Workflow editor styles */
|
||||
}
|
||||
3
client/src/layouts/RightPanel/right-panel.css
Normal file
3
client/src/layouts/RightPanel/right-panel.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.right-panel {
|
||||
/* Right panel base styles */
|
||||
}
|
||||
1
client/src/layouts/RightPanel/stores/.gitkeep
Normal file
1
client/src/layouts/RightPanel/stores/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# RightPanel Stores
|
||||
17
client/src/layouts/RightPanel/stores/useRightPanelStore.ts
Normal file
17
client/src/layouts/RightPanel/stores/useRightPanelStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useRightPanelStore = defineStore('rightPanel', () => {
|
||||
// State
|
||||
const activeTab = ref<'detail' | 'workflow'>('detail');
|
||||
|
||||
// Actions
|
||||
function setActiveTab(tab: 'detail' | 'workflow') {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
};
|
||||
});
|
||||
294
client/src/layouts/TopBar/TopBar.vue
Normal file
294
client/src/layouts/TopBar/TopBar.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-content">
|
||||
<!-- Status Indicators -->
|
||||
<div class="status-section">
|
||||
<!-- Current Character -->
|
||||
<div class="status-badge" title="当前玩家角色">
|
||||
<span class="status-icon">😊</span>
|
||||
<span class="status-label">角色名</span>
|
||||
</div>
|
||||
|
||||
<!-- Current Model -->
|
||||
<div class="status-badge" title="当前模型名(中转站)">
|
||||
<span class="status-icon">🔌</span>
|
||||
<span class="status-label">GPT-4</span>
|
||||
</div>
|
||||
|
||||
<!-- Current Preset -->
|
||||
<div class="status-badge" title="当前预设名">
|
||||
<span class="status-icon">⚙️</span>
|
||||
<span class="status-label">默认预设</span>
|
||||
</div>
|
||||
|
||||
<!-- Active World Info (rightmost, can be longest) -->
|
||||
<div class="status-badge world-info-badge" title="已激活全局世界书">
|
||||
<span class="status-icon">📚</span>
|
||||
<span class="status-label">世界书: 冒险、魔法、科技...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<!-- Settings Button -->
|
||||
<button class="action-btn" title="设置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Extensions Button -->
|
||||
<button class="action-btn" title="扩展">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button class="action-btn theme-toggle" @click="toggleTheme" :title="theme === 'light' ? '切换到夜间模式' : '切换到白天模式'">
|
||||
<svg v-if="theme === 'light'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme';
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-bar {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
z-index: var(--z-sticky);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.top-bar-content {
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Status Section */
|
||||
.status-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 127, 255, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% {
|
||||
transform: translate(-30%, -30%) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(30%, 30%) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-icon::after {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--color-text-primary) 0%, var(--color-text-secondary) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status Badge Styles */
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-fast);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.status-badge:hover {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* World Info badge - allow more space */
|
||||
.world-info-badge {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.world-info-badge .status-label {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid transparent;
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent-light);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.action-btn:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.action-btn:hover svg {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
/* Theme toggle specific styles */
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.theme-toggle:hover::after {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.theme-toggle:hover svg {
|
||||
transform: scale(1.15) rotate(15deg);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
1
client/src/layouts/TopBar/features/.gitkeep
Normal file
1
client/src/layouts/TopBar/features/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# TopBar Features
|
||||
1
client/src/layouts/TopBar/stores/.gitkeep
Normal file
1
client/src/layouts/TopBar/stores/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# TopBar Stores
|
||||
11
client/src/layouts/TopBar/stores/useTopBarStore.ts
Normal file
11
client/src/layouts/TopBar/stores/useTopBarStore.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useTopBarStore = defineStore('topBar', () => {
|
||||
// State
|
||||
const title = ref('SillyTavern Repalice');
|
||||
|
||||
return {
|
||||
title,
|
||||
};
|
||||
});
|
||||
3
client/src/layouts/TopBar/top-bar.css
Normal file
3
client/src/layouts/TopBar/top-bar.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.top-bar {
|
||||
/* Top bar base styles */
|
||||
}
|
||||
@@ -15,17 +15,17 @@ const router = createRouter({
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/components/CenterPanel/CenterPanel.vue'),
|
||||
component: () => import('@/layouts/CenterPanel/CenterPanel.vue'),
|
||||
},
|
||||
{
|
||||
path: 'characters',
|
||||
name: 'Characters',
|
||||
component: () => import('@/components/LeftPanel/LeftPanel.vue'),
|
||||
component: () => import('@/layouts/LeftPanel/LeftPanel.vue'),
|
||||
},
|
||||
{
|
||||
path: 'character/:id',
|
||||
name: 'CharacterDetail',
|
||||
component: () => import('@/components/RightPanel/RightPanel.vue'),
|
||||
component: () => import('@/layouts/RightPanel/features/CharacterDetail/CharacterDetail.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
1
client/src/stores/index.ts
Normal file
1
client/src/stores/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAppStore } from './useAppStore';
|
||||
@@ -14,6 +14,8 @@ body,
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
ul,
|
||||
@@ -47,3 +49,91 @@ video {
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Global animations and transitions */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply subtle animations to interactive elements */
|
||||
button,
|
||||
.action-btn,
|
||||
.add-btn,
|
||||
.edit-btn,
|
||||
.send-btn {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.action-btn:hover,
|
||||
.add-btn:hover,
|
||||
.edit-btn:hover,
|
||||
.send-btn:hover {
|
||||
animation: pulse 0.4s ease;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for the entire page */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,68 @@
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
/* Colors - Dark Theme (Default) */
|
||||
--color-bg-primary: #1a1a1a;
|
||||
--color-bg-secondary: #252525;
|
||||
--color-bg-tertiary: #303030;
|
||||
/* Colors - Dark Theme (Default) - Elegant night mode */
|
||||
--color-bg-primary: #0f1115;
|
||||
--color-bg-secondary: #161920;
|
||||
--color-bg-tertiary: #1c1f27;
|
||||
--color-bg-elevated: #1e2129;
|
||||
--color-bg-subtle: #14171d;
|
||||
|
||||
--color-text-primary: #e0e0e0;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-muted: #808080;
|
||||
--color-text-primary: #e8eaed;
|
||||
--color-text-secondary: #9aa0a6;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-text-inverse: #0f1115;
|
||||
|
||||
--color-border: #404040;
|
||||
--color-border-light: #505050;
|
||||
--color-border: #2d3139;
|
||||
--color-border-light: #242830;
|
||||
--color-border-focus: #3d424d;
|
||||
|
||||
--color-accent: #6c63ff;
|
||||
--color-accent-hover: #7b73ff;
|
||||
--color-accent-active: #5a52d5;
|
||||
--color-accent: #6d8cff;
|
||||
--color-accent-hover: #7d9bff;
|
||||
--color-accent-active: #5b7fff;
|
||||
--color-accent-light: rgba(109, 140, 255, 0.1);
|
||||
--color-accent-ultra-light: rgba(109, 140, 255, 0.05);
|
||||
|
||||
--color-success: #4caf50;
|
||||
--color-warning: #ff9800;
|
||||
--color-error: #f44336;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* Spacing */
|
||||
/* Gradient backgrounds */
|
||||
--gradient-primary: linear-gradient(135deg, #6d8cff 0%, #8da7ff 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(109, 140, 255, 0.05) 0%, transparent 100%);
|
||||
|
||||
/* Spacing - Compact but comfortable */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 24px;
|
||||
--spacing-xl: 32px;
|
||||
--spacing-sm: 6px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 18px;
|
||||
--spacing-xl: 24px;
|
||||
--spacing-2xl: 32px;
|
||||
--spacing-3xl: 40px;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
/* Border Radius - Softer, more refined */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
|
||||
/* Shadows - Subtle and layered for depth */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25);
|
||||
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.35), 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
--shadow-2xl: 0 20px 40px rgba(0, 0, 0, 0.4), 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 350ms ease;
|
||||
/* Transitions - Smooth and elegant */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: 500ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
--transition-smooth: 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
/* Z-index layers */
|
||||
--z-dropdown: 1000;
|
||||
@@ -53,20 +74,45 @@
|
||||
--z-tooltip: 1070;
|
||||
}
|
||||
|
||||
/* Light Theme */
|
||||
[data-theme='light'] {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-bg-secondary: #f5f5f5;
|
||||
--color-bg-tertiary: #e8e8e8;
|
||||
|
||||
--color-text-primary: #212121;
|
||||
--color-text-secondary: #616161;
|
||||
--color-text-muted: #9e9e9e;
|
||||
|
||||
--color-border: #e0e0e0;
|
||||
--color-border-light: #d0d0d0;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.15);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.2);
|
||||
/* Global theme transition */
|
||||
*, *::before, *::after {
|
||||
transition: background-color var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Light Theme - Bright and clean day mode */
|
||||
[data-theme='light'] {
|
||||
--color-bg-primary: #fafbfc;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-tertiary: #f5f6f8;
|
||||
--color-bg-elevated: #ffffff;
|
||||
--color-bg-subtle: #f0f2f5;
|
||||
|
||||
--color-text-primary: #1a1d21;
|
||||
--color-text-secondary: #5a6169;
|
||||
--color-text-muted: #8b9199;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-border: #e8eaed;
|
||||
--color-border-light: #f0f1f3;
|
||||
--color-border-focus: #d1d5db;
|
||||
|
||||
--color-accent: #5b7fff;
|
||||
--color-accent-hover: #4a6ef5;
|
||||
--color-accent-active: #3d5ce0;
|
||||
--color-accent-light: rgba(91, 127, 255, 0.06);
|
||||
--color-accent-ultra-light: rgba(91, 127, 255, 0.03);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #5b7fff 0%, #7c9cff 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, rgba(91, 127, 255, 0.03) 0%, transparent 100%);
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
--shadow-md: 0 4px 8px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.06), 0 4px 8px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.07), 0 6px 12px rgba(0, 0, 0, 0.05);
|
||||
--shadow-2xl: 0 20px 40px rgba(0, 0, 0, 0.08), 0 10px 20px rgba(0, 0, 0, 0.06);
|
||||
--shadow-inner: inset 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"@shared/*": ["../shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
48
docker-compose.dev.yml
Normal file
48
docker-compose.dev.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
# Backend Service - Development Mode
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile.dev
|
||||
container_name: sillytavern-repalice-backend-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./server:/app/server
|
||||
- ./shared:/app/shared
|
||||
- ./data:/app/data
|
||||
- /app/server/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PORT=3000
|
||||
- DATA_DIR=/app/data
|
||||
- FRONTEND_URL=http://localhost:5173
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
command: npm run start:dev
|
||||
|
||||
# Frontend Service - Development Mode (Vite)
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend.Dockerfile.dev
|
||||
container_name: sillytavern-repalice-frontend-dev
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./client:/app/client
|
||||
- ./shared:/app/shared
|
||||
- /app/client/node_modules
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3000/api
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app-network
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
40
docker-compose.prod.yml
Normal file
40
docker-compose.prod.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Production Docker Compose - 使用预构建镜像
|
||||
# 用户无需构建,直接拉取镜像即可运行
|
||||
|
||||
services:
|
||||
# Backend Service
|
||||
backend:
|
||||
image: yourusername/sillytavern-repalice-backend:latest
|
||||
container_name: sillytavern-repalice-backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- DATA_DIR=/app/data
|
||||
- FRONTEND_URL=http://localhost:5173
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Frontend Service
|
||||
frontend:
|
||||
image: yourusername/sillytavern-repalice-frontend:latest
|
||||
container_name: sillytavern-repalice-frontend
|
||||
ports:
|
||||
- "23337:80"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3000/api
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
@@ -1,11 +1,11 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Backend Service
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
args:
|
||||
- BUILDKIT_INLINE_CACHE=1
|
||||
container_name: sillytavern-repalice-backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -27,9 +27,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
args:
|
||||
- BUILDKIT_INLINE_CACHE=1
|
||||
container_name: sillytavern-repalice-frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
- "23337:80"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:3000/api
|
||||
depends_on:
|
||||
|
||||
17
docker-daemon-config.json
Normal file
17
docker-daemon-config.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.m.daocloud.io",
|
||||
"https://huecker.io",
|
||||
"https://dockerhub.timeweb.cloud",
|
||||
"https://noohub.ru",
|
||||
"https://mirror.ccs.tencentyun.com",
|
||||
"https://mirror.baidubce.com"
|
||||
],
|
||||
"builder": {
|
||||
"gc": {
|
||||
"enabled": true,
|
||||
"defaultKeepStorage": "20GB"
|
||||
}
|
||||
},
|
||||
"experimental": false
|
||||
}
|
||||
@@ -3,12 +3,19 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
# Copy package files and npm config
|
||||
COPY server/package*.json ./server/
|
||||
COPY server/.npmrc ./server/
|
||||
COPY shared/package*.json ./shared/
|
||||
|
||||
# Install dependencies
|
||||
RUN cd server && npm ci
|
||||
# Install dependencies with multiple mirror fallback
|
||||
RUN cd server && \
|
||||
echo "Trying Aliyun mirror..." && \
|
||||
npm install --registry=https://registry.npmmirror.com --prefer-offline --no-audit --no-fund || \
|
||||
(echo "Aliyun failed, trying Tencent Cloud..." && \
|
||||
npm install --registry=https://mirrors.cloud.tencent.com/npm/ --prefer-offline --no-audit --no-fund) || \
|
||||
(echo "Tencent failed, trying Huawei Cloud..." && \
|
||||
npm install --registry=https://repo.huaweicloud.com/repository/npm/ --prefer-offline --no-audit --no-fund)
|
||||
|
||||
# Copy source code
|
||||
COPY server/src ./server/src
|
||||
@@ -24,9 +31,15 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install production dependencies
|
||||
# Copy package files and npm config
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY server/.npmrc ./
|
||||
|
||||
# Install production dependencies with multiple mirror fallback
|
||||
RUN echo "Installing production dependencies..." && \
|
||||
(npm install --omit=dev --registry=https://registry.npmmirror.com --prefer-offline --no-audit --no-fund || \
|
||||
npm install --omit=dev --registry=https://mirrors.cloud.tencent.com/npm/ --prefer-offline --no-audit --no-fund || \
|
||||
npm install --omit=dev --registry=https://repo.huaweicloud.com/repository/npm/ --prefer-offline --no-audit --no-fund)
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/server/dist ./dist
|
||||
|
||||
@@ -3,12 +3,19 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
# Copy package files and npm config
|
||||
COPY client/package*.json ./client/
|
||||
COPY client/.npmrc ./client/
|
||||
COPY shared/package*.json ./shared/
|
||||
|
||||
# Install dependencies
|
||||
RUN cd client && npm ci
|
||||
# Install dependencies with multiple mirror fallback
|
||||
RUN cd client && \
|
||||
echo "Trying Aliyun mirror for frontend..." && \
|
||||
npm install --registry=https://registry.npmmirror.com --prefer-offline --no-audit --no-fund || \
|
||||
(echo "Aliyun failed, trying Tencent Cloud..." && \
|
||||
npm install --registry=https://mirrors.cloud.tencent.com/npm/ --prefer-offline --no-audit --no-fund) || \
|
||||
(echo "Tencent failed, trying Huawei Cloud..." && \
|
||||
npm install --registry=https://repo.huaweicloud.com/repository/npm/ --prefer-offline --no-audit --no-fund)
|
||||
|
||||
# Copy source code
|
||||
COPY client ./client
|
||||
|
||||
29
docker/frontend.Dockerfile.dev
Normal file
29
docker/frontend.Dockerfile.dev
Normal file
@@ -0,0 +1,29 @@
|
||||
# Frontend Development Dockerfile - Vue3 + Vite (No Nginx)
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and npm config
|
||||
COPY client/package*.json ./client/
|
||||
COPY client/.npmrc ./client/
|
||||
COPY shared/package*.json ./shared/
|
||||
|
||||
# Install dependencies with multiple mirror fallback
|
||||
RUN cd client && \
|
||||
echo "Trying Aliyun mirror for frontend..." && \
|
||||
npm install --registry=https://registry.npmmirror.com --prefer-offline --no-audit --no-fund || \
|
||||
(echo "Aliyun failed, trying Tencent Cloud..." && \
|
||||
npm install --registry=https://mirrors.cloud.tencent.com/npm/ --prefer-offline --no-audit --no-fund) || \
|
||||
(echo "Tencent failed, trying Huawei Cloud..." && \
|
||||
npm install --registry=https://repo.huaweicloud.com/repository/npm/ --prefer-offline --no-audit --no-fund)
|
||||
|
||||
# Copy source code
|
||||
COPY client ./client
|
||||
COPY shared ./shared
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start Vite dev server
|
||||
WORKDIR /app/client
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
12905
package-lock.json
generated
Normal file
12905
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
publish-docker.bat
Normal file
46
publish-docker.bat
Normal file
@@ -0,0 +1,46 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo ========================================
|
||||
echo Build and Push Docker Images
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set /p version="Enter version tag (e.g., 1.0.0): "
|
||||
if "%version%"=="" (
|
||||
echo Error: Version tag is required!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set IMAGE_PREFIX=sillytavern-repalice
|
||||
|
||||
echo.
|
||||
echo Step 1: Building images...
|
||||
docker-compose build
|
||||
if errorlevel 1 (
|
||||
echo Build failed!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Step 2: Tagging images...
|
||||
docker tag %IMAGE_PREFIX%-backend:latest %IMAGE_PREFIX%/backend:%version%
|
||||
docker tag %IMAGE_PREFIX%-backend:latest %IMAGE_PREFIX%/backend:latest
|
||||
docker tag %IMAGE_PREFIX%-frontend:latest %IMAGE_PREFIX%/frontend:%version%
|
||||
docker tag %IMAGE_PREFIX%-frontend:latest %IMAGE_PREFIX%/frontend:latest
|
||||
|
||||
echo.
|
||||
echo Step 3: Pushing to Docker Hub...
|
||||
docker push %IMAGE_PREFIX%/backend:%version%
|
||||
docker push %IMAGE_PREFIX%/backend:latest
|
||||
docker push %IMAGE_PREFIX%/frontend:%version%
|
||||
docker push %IMAGE_PREFIX%/frontend:latest
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Images pushed successfully!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Users can now run:
|
||||
echo docker pull %IMAGE_PREFIX%/backend:%version%
|
||||
echo docker pull %IMAGE_PREFIX%/frontend:%version%
|
||||
echo.
|
||||
7
server/.npmrc
Normal file
7
server/.npmrc
Normal file
@@ -0,0 +1,7 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
fetch-timeout=600000
|
||||
maxsockets=10
|
||||
prefer-offline=true
|
||||
audit=false
|
||||
fund=false
|
||||
loglevel=error
|
||||
7
server/.npmrc.tencent
Normal file
7
server/.npmrc.tencent
Normal file
@@ -0,0 +1,7 @@
|
||||
registry=https://mirrors.cloud.tencent.com/npm/
|
||||
fetch-timeout=600000
|
||||
maxsockets=10
|
||||
prefer-offline=true
|
||||
audit=false
|
||||
fund=false
|
||||
loglevel=error
|
||||
@@ -26,7 +26,12 @@
|
||||
"@ai-sdk/openai": "^1.0.0",
|
||||
"@ai-sdk/anthropic": "^1.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"langchain": "^0.1.0",
|
||||
"@langchain/core": "^0.1.0",
|
||||
"@langchain/openai": "^0.0.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-transformer": "^0.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller, Get, Post, Body, Param, Delete } from '@nestjs/common';
|
||||
|
||||
@Controller('characters')
|
||||
export class CharacterController {
|
||||
@Get()
|
||||
findAll() {
|
||||
return [];
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: any) {
|
||||
return body;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return { id, deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user