Compare commits
18 Commits
feat/full-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
2055b62afd | |
|
|
6037bf2bd6 | |
|
|
d6f222c2e0 | |
|
|
0028091f34 | |
|
|
0bcba03393 | |
|
|
6234c27138 | |
|
|
9f4ee690db | |
|
|
a272c62b97 | |
|
|
18a46c9af5 | |
|
|
5a7155ecbc | |
|
|
7725cf1f65 | |
|
|
d9d6404218 | |
|
|
2a13044f12 | |
|
|
4d1679045c | |
|
|
c91f49e1f7 | |
|
|
6ce6b8a464 | |
|
|
75d2271cbe | |
|
|
7db0dab973 |
|
|
@ -0,0 +1,18 @@
|
||||||
|
# ===== EternalAI 环境变量配置 =====
|
||||||
|
# 复制此文件为 .env 并填写真实值
|
||||||
|
# cp .env.example .env
|
||||||
|
|
||||||
|
# 数据库连接字符串
|
||||||
|
# 格式: postgresql://用户名:密码@主机:端口/数据库名
|
||||||
|
DATABASE_URL="postgresql://eternalai:YOUR_PASSWORD@localhost:5432/eternalai"
|
||||||
|
|
||||||
|
# JWT 密钥 — 生产环境必须修改为随机长字符串(至少 32 字节)
|
||||||
|
# 生成方法: openssl rand -base64 48
|
||||||
|
JWT_SECRET="PLEASE_CHANGE_THIS_TO_A_RANDOM_SECRET"
|
||||||
|
|
||||||
|
# 服务端口
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# CORS 允许的来源(逗号分隔,生产环境应限制为实际域名)
|
||||||
|
# 留空则允许所有来源(不推荐)
|
||||||
|
ALLOWED_ORIGINS=""
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
name: Deploy EternalAI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Generate Prisma Client
|
||||||
|
run: npx prisma generate
|
||||||
|
|
||||||
|
- name: Push database schema
|
||||||
|
run: npx prisma db push
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
|
||||||
|
- name: Create logs directory
|
||||||
|
run: mkdir -p logs
|
||||||
|
|
||||||
|
- name: Restart PM2 process
|
||||||
|
run: |
|
||||||
|
if pm2 describe eternalai &>/dev/null; then
|
||||||
|
pm2 reload ecosystem.config.js --update-env
|
||||||
|
echo "PM2 process reloaded"
|
||||||
|
else
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
echo "PM2 process started"
|
||||||
|
fi
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
- name: Health check
|
||||||
|
run: |
|
||||||
|
sleep 3
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001 || echo "000")
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Health check passed (HTTP $HTTP_CODE)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting for server... (attempt $i, HTTP $HTTP_CODE)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Health check failed"
|
||||||
|
pm2 logs eternalai --lines 30 --nostream
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Reload Nginx
|
||||||
|
run: sudo nginx -t && sudo nginx -s reload || echo "Nginx reload skipped"
|
||||||
|
|
||||||
|
- name: Deploy summary
|
||||||
|
run: |
|
||||||
|
echo "============================================"
|
||||||
|
echo " Deploy completed: $(git rev-parse --short HEAD)"
|
||||||
|
echo " $(git log -1 --format='%s')"
|
||||||
|
echo "============================================"
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
logs/
|
||||||
Binary file not shown.
307
README.md
307
README.md
|
|
@ -1,3 +1,306 @@
|
||||||
# EternalAI
|
# Eternal AI
|
||||||
|
|
||||||
EternalAI Project Repository// Update to main branch
|
AI 陪伴平台 — 人设创作者设定并发布角色,自动生成 Hermes Agent 可用的配置文件(SOUL.md + config.yaml),支持跨机器 CLI 拉取部署。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 浏览器 (SPA) │
|
||||||
|
│ index.html + app.js + styles.css │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
|
||||||
|
│ │ 认证视图 │ │ 角色库 │ │ 创作者中心 │ │ 角色编辑器 │ │
|
||||||
|
│ └──────────┘ └──────────┘ └───────────┘ └───────────┘ │
|
||||||
|
└──────────────────────┬───────────────────────────────────┘
|
||||||
|
│ HTTP / JSON
|
||||||
|
┌──────────────────────▼───────────────────────────────────┐
|
||||||
|
│ Express.js 服务端 (server.js) │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │
|
||||||
|
│ │ /api/auth│ │/api/roles│ │/api/apikeys│ │ /api/hermes │ │
|
||||||
|
│ └────┬────┘ └────┬────┘ └─────┬────┘ └──────┬────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ ┌────▼───────────▼────────────▼──────────────▼────────┐ │
|
||||||
|
│ │ 认证中间件 (src/lib/auth.js) │ │
|
||||||
|
│ │ JWT 认证 (authMiddleware) + API Key 认证 │ │
|
||||||
|
│ │ (apiKeyMiddleware, eak_ 前缀, SHA-256 哈希) │ │
|
||||||
|
│ └─────────────────────┬───────────────────────────────┘ │
|
||||||
|
└────────────────────────┼─────────────────────────────────┘
|
||||||
|
│ Prisma ORM
|
||||||
|
┌────────────────────────▼─────────────────────────────────┐
|
||||||
|
│ PostgreSQL 数据库 │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ User │ │ Role │ │ ApiKey │ │ Order │ │
|
||||||
|
│ └────────┘ └────────┘ └────────┘ └────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
| 层 | 技术 |
|
||||||
|
|----|------|
|
||||||
|
| 前端 | 原生 HTML5 SPA(IIFE 模式,无框架)、玻璃拟态 UI |
|
||||||
|
| 后端 | Node.js + Express 5.x |
|
||||||
|
| 数据库 | PostgreSQL 15 + Prisma ORM 5.x |
|
||||||
|
| 认证 | JWT(7 天有效期)+ API Key(`eak_` 前缀,SHA-256 哈希存储) |
|
||||||
|
| 密码 | bcryptjs(10 轮盐) |
|
||||||
|
| 测试 | Playwright E2E(35 个测试用例) |
|
||||||
|
| 部署 | PM2 + Nginx 反向代理 |
|
||||||
|
|
||||||
|
### 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
EternalAI/
|
||||||
|
├── server.js # Express 入口,注册路由 + 静态文件
|
||||||
|
├── app.js # 前端 SPA 逻辑(路由、状态、表单、API 调用)
|
||||||
|
├── index.html # 单页应用入口(9 个视图)
|
||||||
|
├── styles.css # 玻璃拟态样式
|
||||||
|
├── prisma/
|
||||||
|
│ └── schema.prisma # 数据模型定义(User / Role / ApiKey / Order)
|
||||||
|
├── src/
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── auth.js # JWT + API Key 认证中间件
|
||||||
|
│ │ └── prisma.js # Prisma Client 单例
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── auth.js # 注册 / 登录 / 用户信息 / 设置
|
||||||
|
│ ├── roles.js # 角色 CRUD + 角色库
|
||||||
|
│ ├── apikeys.js # API Key 生成 / 列表 / 删除
|
||||||
|
│ └── hermes.js # Hermes 配置拉取端点(SOUL.md / config.yaml)
|
||||||
|
├── deploy/
|
||||||
|
│ ├── setup-server.sh # 服务器一键初始化脚本
|
||||||
|
│ ├── deploy.sh # CI/CD 部署脚本
|
||||||
|
│ └── nginx.conf # Nginx 反向代理配置
|
||||||
|
├── e2e/ # Playwright E2E 测试
|
||||||
|
├── docs/ # 需求文档与规划
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,设置 DATABASE_URL 和 JWT_SECRET
|
||||||
|
# JWT_SECRET 可用 openssl rand -hex 32 生成
|
||||||
|
|
||||||
|
# 3. 初始化数据库
|
||||||
|
npx prisma db push
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# 4. 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务器部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上执行一键初始化脚本
|
||||||
|
bash deploy/setup-server.sh
|
||||||
|
# 自动安装 Node.js / PostgreSQL / Nginx / PM2,创建 .env,推送 Schema
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作流程
|
||||||
|
|
||||||
|
### 1. 用户注册与登录
|
||||||
|
|
||||||
|
```
|
||||||
|
用户 → 首页 → 登录/注册视图
|
||||||
|
├─ 注册:填写账号 + 密码(≥6位)→ 自动登录,返回 JWT
|
||||||
|
└─ 登录:账号 + 密码 → 验证成功,返回 JWT
|
||||||
|
└─ JWT 存入 localStorage,后续请求携带 Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创作者入驻与角色设定
|
||||||
|
|
||||||
|
```
|
||||||
|
登录用户 → 创作者入驻页 → 开通创作者身份
|
||||||
|
→ 创作者管理中心(三个 Tab)
|
||||||
|
├─ 角色 Tab:新建 / 编辑角色
|
||||||
|
├─ 收入 Tab:余额 + 付费流水
|
||||||
|
└─ 设置 Tab:昵称 / 角色库名 / API Key 管理
|
||||||
|
|
||||||
|
新建角色(4 步表单):
|
||||||
|
Step 1 → 基础信息(名称、性别、年龄、关系)
|
||||||
|
Step 2 → 性格设定(性格、背景、说话风格、喜好)
|
||||||
|
Step 3 → 模型配置(模型、温度、最大 Token、记忆/工具开关)
|
||||||
|
Step 4 → 发布设置(问候语、价格、描述)
|
||||||
|
→ 提交后自动生成 SOUL.md + config.yaml 存入数据库
|
||||||
|
→ 角色上架到角色库
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 角色库浏览与购买
|
||||||
|
|
||||||
|
```
|
||||||
|
任意用户 → 角色库 → 浏览已上架角色
|
||||||
|
→ 点击角色卡片 → 查看详情
|
||||||
|
→ 付款 → 创建订单 → 解锁角色
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 Hermes Agent 交互流程
|
||||||
|
|
||||||
|
EternalAI 生成的角色配置(SOUL.md + config.yaml)可通过 CLI 拉取命令部署到任意 Hermes Agent 机器上,支持跨机器部署。
|
||||||
|
|
||||||
|
### 架构图
|
||||||
|
|
||||||
|
```
|
||||||
|
服务器 A (EternalAI) 服务器 B (Hermes Agent)
|
||||||
|
┌───────────────────────┐ ┌───────────────────────┐
|
||||||
|
│ 1. 设置页生成 API Key │ │ │
|
||||||
|
│ (eak_xxxxxxxxxx) │ │ 5. hermes profile │
|
||||||
|
│ │ │ create role-mio │
|
||||||
|
│ 2. 创建角色 → 发布 │ │ │
|
||||||
|
│ 生成 SOUL.md + │ │ 6. curl 拉取文件 │
|
||||||
|
│ config.yaml │ │ SOUL.md │
|
||||||
|
│ │ │ config.yaml │
|
||||||
|
│ 3. 角色管理页显示 │ 4. 用户复制 │ │
|
||||||
|
│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │
|
||||||
|
│ (含 API Key) │ 命令执行 │ (API 密钥) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 8. role-mio chat │
|
||||||
|
└───────────────────────┘ └───────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤详解
|
||||||
|
|
||||||
|
#### Step 1-3:在 EternalAI Web UI 操作
|
||||||
|
|
||||||
|
1. **生成 API Key**:创作者管理中心 → 设置 Tab → 点击「生成 API Key」→ 明文 Key 仅显示一次,请妥善保存
|
||||||
|
2. **创建角色**:创作者管理中心 → 角色 Tab → 新建角色 → 填写 4 步表单 → 发布
|
||||||
|
3. **获取部署命令**:角色卡片点击「Hermes 部署」→ 弹窗显示完整的 curl 命令模板(含服务器地址、角色 ID、API Key)→ 点击复制
|
||||||
|
|
||||||
|
#### Step 4-8:在 Hermes Agent 机器上操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 4. 复制 curl 命令到 Hermes 机器执行
|
||||||
|
|
||||||
|
# 5. 创建 Hermes profile
|
||||||
|
hermes profile create role-mio
|
||||||
|
|
||||||
|
# 6. 拉取 SOUL.md
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://your-eternalai-domain.com/api/hermes/roles/<role-id>/SOUL.md \
|
||||||
|
-o ~/.hermes/profiles/role-mio/SOUL.md
|
||||||
|
|
||||||
|
# 7. 拉取 config.yaml
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://your-eternalai-domain.com/api/hermes/roles/<role-id>/config.yaml \
|
||||||
|
-o ~/.hermes/profiles/role-mio/config.yaml
|
||||||
|
|
||||||
|
# 8. 配置 API 密钥并启动
|
||||||
|
echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env
|
||||||
|
role-mio chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### config.yaml 格式适配
|
||||||
|
|
||||||
|
EternalAI 角色数据自动转换为 Hermes config.yaml 格式:
|
||||||
|
|
||||||
|
| EternalAI 字段 | Hermes config.yaml 字段 |
|
||||||
|
|---|---|
|
||||||
|
| `role.model` | `model:` |
|
||||||
|
| `role.temperature` | `temperature:` |
|
||||||
|
| `role.maxTokens` | `max_tokens:` |
|
||||||
|
| `role.enableMemory` | `memory.enabled:` |
|
||||||
|
| `role.enableTools` | `tools.enabled:` |
|
||||||
|
|
||||||
|
生成的 config.yaml 示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Hermes Agent Config — generated by EternalAI
|
||||||
|
# Character: Mio
|
||||||
|
# Agent ID: default
|
||||||
|
# Generated at: 2026-06-21T12:00:00.000Z
|
||||||
|
|
||||||
|
model: "gpt-4o"
|
||||||
|
temperature: 0.85
|
||||||
|
max_tokens: 2048
|
||||||
|
|
||||||
|
memory:
|
||||||
|
enabled: true
|
||||||
|
storage: local
|
||||||
|
|
||||||
|
tools:
|
||||||
|
enabled: true
|
||||||
|
toolsets:
|
||||||
|
- memory
|
||||||
|
|
||||||
|
terminal:
|
||||||
|
backend: local
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key 安全机制
|
||||||
|
|
||||||
|
- **格式**:`eak_` + 32 位随机 hex(128 位熵)
|
||||||
|
- **存储**:SHA-256 哈希存储,明文仅在生成时返回一次
|
||||||
|
- **脱敏显示**:列表中仅显示前 12 位(如 `eak_a1b2c3d4`)
|
||||||
|
- **认证**:通过 `Authorization: Bearer eak_xxx` 头传递
|
||||||
|
- **权限**:API Key 只能拉取所属用户创建的角色配置
|
||||||
|
- **可吊销**:随时在设置页删除,删除后立即失效
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 认证 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/auth/register` | 无 | 注册(账号 + 密码) |
|
||||||
|
| POST | `/api/auth/login` | 无 | 登录 |
|
||||||
|
| GET | `/api/auth/me` | JWT | 获取当前用户信息 |
|
||||||
|
| PUT | `/api/auth/settings` | JWT | 更新用户设置 |
|
||||||
|
|
||||||
|
### 角色 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/roles` | 无 | 获取角色库(已上架) |
|
||||||
|
| GET | `/api/roles/:id` | 无 | 获取角色详情 |
|
||||||
|
| GET | `/api/roles/my/roles` | JWT | 获取我创建的角色 |
|
||||||
|
| POST | `/api/roles` | JWT | 发布新角色 |
|
||||||
|
| PUT | `/api/roles/:id` | JWT | 编辑角色 |
|
||||||
|
| GET | `/api/roles/:id/full` | JWT | 获取角色完整信息(含 SOUL.md) |
|
||||||
|
|
||||||
|
### API Key 管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/apikeys` | JWT | 生成 API Key(明文仅返回一次) |
|
||||||
|
| GET | `/api/apikeys` | JWT | 列出所有 API Key(脱敏) |
|
||||||
|
| DELETE | `/api/apikeys/:id` | JWT | 删除 API Key |
|
||||||
|
|
||||||
|
### Hermes 配置拉取
|
||||||
|
|
||||||
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/api/hermes/roles/:id/SOUL.md` | API Key / JWT | 拉取 SOUL.md(text/plain) |
|
||||||
|
| GET | `/api/hermes/roles/:id/config.yaml` | API Key / JWT | 拉取 config.yaml(text/plain) |
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行全部 E2E 测试(35 个用例)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# 查看测试报告
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
测试覆盖:认证流程、角色发布/编辑、角色库浏览、导航与可访问性。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- **JWT 密钥**:生产环境必须设置 `JWT_SECRET` 环境变量,未设置时 fail-fast 抛错
|
||||||
|
- **密码存储**:bcrypt 哈希(10 轮盐),不存明文
|
||||||
|
- **API Key 存储**:SHA-256 哈希存储,明文仅显示一次
|
||||||
|
- **XSS 防护**:所有用户可控数据通过 `escapeHtml()` 转义后插入 DOM
|
||||||
|
- **YAML 注入防护**:config.yaml 生成时对用户可控字段做双引号转义和换行符过滤
|
||||||
|
- **所有权校验**:所有角色操作验证 `creatorId === req.userId`
|
||||||
|
- **.env 文件权限**:部署脚本自动设置 `chmod 600`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ===== EternalAI 部署脚本 =====
|
||||||
|
# 每次部署时运行,拉取最新代码、安装依赖、迁移数据库、重启服务
|
||||||
|
# 用法: bash deploy/deploy.sh
|
||||||
|
|
||||||
|
# ---- 颜色输出 ----
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
HEALTH_URL="http://localhost:3001"
|
||||||
|
HEALTH_TIMEOUT=30
|
||||||
|
|
||||||
|
cd "$APP_DIR"
|
||||||
|
info "应用目录: $APP_DIR"
|
||||||
|
|
||||||
|
# ---- 前置检查 ----
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
error ".env 文件不存在,请先运行 deploy/setup-server.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pm2 &>/dev/null; then
|
||||||
|
error "PM2 未安装,请先运行 deploy/setup-server.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 拉取最新代码 ----
|
||||||
|
info "拉取最新代码..."
|
||||||
|
git fetch --all
|
||||||
|
git reset --hard origin/master
|
||||||
|
info "当前版本: $(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
# ---- 安装依赖 ----
|
||||||
|
info "安装 npm 依赖..."
|
||||||
|
npm install --production=false
|
||||||
|
|
||||||
|
# ---- 生成 Prisma Client ----
|
||||||
|
info "生成 Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# ---- 数据库迁移 ----
|
||||||
|
info "推送数据库 Schema..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# ---- 创建日志目录 ----
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# ---- 重启 PM2 进程 ----
|
||||||
|
info "重启 PM2 进程..."
|
||||||
|
if pm2 describe eternalai &>/dev/null; then
|
||||||
|
pm2 reload ecosystem.config.js --update-env
|
||||||
|
info "PM2 进程已 reload"
|
||||||
|
else
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
info "PM2 进程已启动"
|
||||||
|
fi
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
# ---- 健康检查 ----
|
||||||
|
info "健康检查 (${HEALTH_TIMEOUT}s 超时)..."
|
||||||
|
ELAPSED=0
|
||||||
|
while [[ $ELAPSED -lt $HEALTH_TIMEOUT ]]; do
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||||
|
if [[ "$HTTP_CODE" == "200" ]]; then
|
||||||
|
info "健康检查通过 (HTTP $HTTP_CODE)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
ELAPSED=$((ELAPSED + 2))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$HTTP_CODE" != "200" ]]; then
|
||||||
|
error "健康检查失败 (HTTP $HTTP_CODE),请检查日志: pm2 logs eternalai"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 完成 ----
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " 部署完成!"
|
||||||
|
info "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "版本: $(git rev-parse --short HEAD)"
|
||||||
|
echo "分支: $(git branch --show-current)"
|
||||||
|
echo "状态: pm2 status"
|
||||||
|
echo "日志: pm2 logs eternalai"
|
||||||
|
echo ""
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
# ===== EternalAI Nginx 反向代理配置 =====
|
||||||
|
#
|
||||||
|
# 使用方法:
|
||||||
|
# 1. 复制到 Nginx 配置目录:
|
||||||
|
# sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||||
|
# 2. 替换 YOUR_DOMAIN 为实际域名
|
||||||
|
# 3. 测试配置: sudo nginx -t
|
||||||
|
# 4. 重载: sudo nginx -s reload
|
||||||
|
#
|
||||||
|
# HTTPS 配置(推荐):
|
||||||
|
# sudo certbot --nginx -d YOUR_DOMAIN
|
||||||
|
|
||||||
|
# 替换 YOUR_DOMAIN 为你的实际域名
|
||||||
|
upstream eternalai_backend {
|
||||||
|
server 127.0.0.1:3001;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name YOUR_DOMAIN;
|
||||||
|
|
||||||
|
# 安全头
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
# 请求体大小限制
|
||||||
|
client_max_body_size 10m;
|
||||||
|
|
||||||
|
# API 请求代理到 Node.js
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态文件缓存
|
||||||
|
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主页和其他路由
|
||||||
|
location / {
|
||||||
|
proxy_pass http://eternalai_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问敏感文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /\.(env|git) {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /node_modules/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /prisma/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /e2e/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ~ /deploy/ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/eternalai_access.log;
|
||||||
|
error_log /var/log/nginx/eternalai_error.log;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ===== EternalAI 服务器初始化脚本 =====
|
||||||
|
# 首次部署时在服务器上运行,自动检测并安装所有依赖
|
||||||
|
# 用法: bash deploy/setup-server.sh
|
||||||
|
#
|
||||||
|
# 可通过环境变量自定义:
|
||||||
|
# DB_NAME=eternalai 数据库名
|
||||||
|
# DB_USER=eternalai 数据库用户
|
||||||
|
# DB_PASS=xxx 数据库密码(必填,否则自动生成)
|
||||||
|
|
||||||
|
# ---- 颜色输出 ----
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# ---- 配置 ----
|
||||||
|
DB_NAME="${DB_NAME:-eternalai}"
|
||||||
|
DB_USER="${DB_USER:-eternalai}"
|
||||||
|
DB_PASS="${DB_PASS:-$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)}"
|
||||||
|
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
NODE_VERSION="20"
|
||||||
|
|
||||||
|
info "应用目录: $APP_DIR"
|
||||||
|
info "数据库: $DB_NAME (用户: $DB_USER)"
|
||||||
|
|
||||||
|
# ---- 检测 OS ----
|
||||||
|
if [[ -f /etc/os-release ]]; then
|
||||||
|
. /etc/os-release
|
||||||
|
OS_ID="$ID"
|
||||||
|
info "检测到操作系统: $PRETTY_NAME"
|
||||||
|
else
|
||||||
|
error "无法检测操作系统,仅支持 Ubuntu/Debian/CentOS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 Node.js ----
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
info "安装 Node.js $NODE_VERSION LTS..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||||
|
sudo yum install -y nodejs
|
||||||
|
else
|
||||||
|
error "不支持的操作系统: $OS_ID,请手动安装 Node.js $NODE_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Node.js 安装完成: $(node -v)"
|
||||||
|
else
|
||||||
|
info "Node.js 已安装: $(node -v)"
|
||||||
|
NODE_MAJOR=$(node -v | sed 's/v\([0-9]*\).*/\1/')
|
||||||
|
if [[ "$NODE_MAJOR" -lt 18 ]]; then
|
||||||
|
warn "Node.js 版本过低 (当前 $(node -v)),建议升级到 $NODE_VERSION LTS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 PM2 ----
|
||||||
|
if ! command -v pm2 &>/dev/null; then
|
||||||
|
info "安装 PM2..."
|
||||||
|
sudo npm install -g pm2
|
||||||
|
info "PM2 安装完成: $(pm2 -v)"
|
||||||
|
else
|
||||||
|
info "PM2 已安装: $(pm2 -v)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 PostgreSQL ----
|
||||||
|
if ! command -v psql &>/dev/null; then
|
||||||
|
info "安装 PostgreSQL 15..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
sudo apt-get install -y ca-certificates curl gnupg lsb-release
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql-15
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
sudo yum install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-x86_64/pgdg-redhat-repo-latest.noarch.rpm"
|
||||||
|
sudo yum install -y postgresql15-server postgresql15
|
||||||
|
sudo /usr/pgsql-15/bin/postgresql-15-setup initdb
|
||||||
|
else
|
||||||
|
error "不支持的操作系统: $OS_ID,请手动安装 PostgreSQL 15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "PostgreSQL 安装完成"
|
||||||
|
else
|
||||||
|
info "PostgreSQL 已安装: $(psql --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 启动 PostgreSQL ----
|
||||||
|
if ! sudo systemctl is-active --quiet postgresql; then
|
||||||
|
info "启动 PostgreSQL 服务..."
|
||||||
|
sudo systemctl enable postgresql
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建数据库和用户 ----
|
||||||
|
info "创建数据库 $DB_NAME 和用户 $DB_USER..."
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || {
|
||||||
|
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||||
|
info "数据库用户 $DB_USER 已创建"
|
||||||
|
}
|
||||||
|
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || {
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||||
|
info "数据库 $DB_NAME 已创建"
|
||||||
|
}
|
||||||
|
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" >/dev/null
|
||||||
|
|
||||||
|
# 确保 PostgreSQL 对 pg_hba.conf 允许密码认证
|
||||||
|
PG_HBA=$(sudo -u postgres psql -t -c "SHOW hba_file" | xargs)
|
||||||
|
if [[ -f "$PG_HBA" ]]; then
|
||||||
|
if ! sudo grep -q "$DB_USER" "$PG_HBA" 2>/dev/null; then
|
||||||
|
warn "如果连接数据库失败,请检查 $PG_HBA 是否允许 md5/scram-sha-256 认证"
|
||||||
|
warn "添加: host $DB_NAME $DB_USER 127.0.0.1/32 scram-sha-256"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 安装 Nginx ----
|
||||||
|
if ! command -v nginx &>/dev/null; then
|
||||||
|
info "安装 Nginx..."
|
||||||
|
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||||
|
sudo apt-get install -y nginx
|
||||||
|
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||||
|
sudo yum install -y nginx
|
||||||
|
fi
|
||||||
|
info "Nginx 安装完成: $(nginx -v 2>&1)"
|
||||||
|
else
|
||||||
|
info "Nginx 已安装: $(nginx -v 2>&1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! sudo systemctl is-active --quiet nginx; then
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建 .env 文件 ----
|
||||||
|
ENV_FILE="$APP_DIR/.env"
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
info "创建 .env 文件..."
|
||||||
|
JWT_SECRET=$(openssl rand -base64 48)
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
|
||||||
|
JWT_SECRET="$JWT_SECRET"
|
||||||
|
PORT=3001
|
||||||
|
EOF
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
info ".env 文件已创建(权限 600,请妥善保管数据库密码和 JWT 密钥)"
|
||||||
|
echo ""
|
||||||
|
echo "=============================="
|
||||||
|
echo " JWT 密钥已自动生成"
|
||||||
|
echo " 数据库密码已写入 .env 文件"
|
||||||
|
echo "=============================="
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
info ".env 文件已存在,跳过创建"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 创建日志目录 ----
|
||||||
|
mkdir -p "$APP_DIR/logs"
|
||||||
|
|
||||||
|
# ---- 安装项目依赖 ----
|
||||||
|
info "安装项目依赖..."
|
||||||
|
cd "$APP_DIR"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# ---- 生成 Prisma Client ----
|
||||||
|
info "生成 Prisma Client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
# ---- 推送数据库 Schema ----
|
||||||
|
info "推送数据库 Schema..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# ---- 配置 PM2 开机自启 ----
|
||||||
|
info "配置 PM2 开机自启..."
|
||||||
|
pm2 start ecosystem.config.js || true
|
||||||
|
pm2 save
|
||||||
|
pm2 startup systemd -u "$(whoami)" --hp "$HOME" 2>/dev/null | grep "sudo" | bash || true
|
||||||
|
|
||||||
|
# ---- 完成 ----
|
||||||
|
echo ""
|
||||||
|
info "============================================"
|
||||||
|
info " 服务器初始化完成!"
|
||||||
|
info "============================================"
|
||||||
|
echo ""
|
||||||
|
echo "下一步:"
|
||||||
|
echo " 1. 配置 Nginx 反向代理(参考 deploy/nginx.conf)"
|
||||||
|
echo " 2. 检查应用状态: pm2 status"
|
||||||
|
echo " 3. 查看日志: pm2 logs eternalai"
|
||||||
|
echo " 4. 访问应用: http://localhost:3001"
|
||||||
|
echo ""
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Hermes Agent 跨机器部署 — 需求文档
|
||||||
|
|
||||||
|
**日期**: 2026-06-20
|
||||||
|
**状态**: 已确认,待实现
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
EternalAI 生成的角色配置(Soul.md + config.yaml)需要能直接复制到远程 Hermes Agent 中使用。EternalAI 服务器与 Hermes Agent 可能不在同一台机器上,用户通过 CLI 拉取命令(curl)跨机器获取配置文件。
|
||||||
|
|
||||||
|
## 用户场景
|
||||||
|
|
||||||
|
用户在 EternalAI Web UI 创建角色后,需要在另一台安装了 Hermes Agent 的机器上部署该角色。用户不希望 EternalAI 直接写入 Hermes 目录,而是生成可直接使用的文件,通过 curl 命令拉取后手动放置到 Hermes profile 目录。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```
|
||||||
|
服务器 A (EternalAI) 服务器 B (Hermes Agent)
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ 1. 设置页生成 API Key│ │ │
|
||||||
|
│ (eak_xxxxx) │ │ 5. hermes profile │
|
||||||
|
│ │ │ create role-mio │
|
||||||
|
│ 2. 创建角色 → 发布 │ │ │
|
||||||
|
│ 生成 Soul.md + │ │ 6. curl 拉取文件 │
|
||||||
|
│ config.yaml │ │ SOUL.md │
|
||||||
|
│ │ │ config.yaml │
|
||||||
|
│ 3. 角色管理页显示 │ 4. 用户复制 │ │
|
||||||
|
│ curl 命令模板 │──── curl ────▶│ 7. 配置 .env │
|
||||||
|
│ (含 API Key) │ 命令执行 │ (API 密钥) │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 8. role-mio chat │
|
||||||
|
└─────────────────────┘ └─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 用户在 Hermes 机器上的操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建 profile
|
||||||
|
hermes profile create role-mio
|
||||||
|
|
||||||
|
# 2. 拉取 SOUL.md
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://eternalai.example.com/api/hermes/roles/<id>/SOUL.md \
|
||||||
|
-o ~/.hermes/profiles/role-mio/SOUL.md
|
||||||
|
|
||||||
|
# 3. 拉取 config.yaml
|
||||||
|
curl -H "Authorization: Bearer eak_xxx" \
|
||||||
|
https://eternalai.example.com/api/hermes/roles/<id>/config.yaml \
|
||||||
|
-o ~/.hermes/profiles/role-mio/config.yaml
|
||||||
|
|
||||||
|
# 4. 配置 API 密钥
|
||||||
|
echo "OPENROUTER_API_KEY=sk-or-xxx" > ~/.hermes/profiles/role-mio/.env
|
||||||
|
|
||||||
|
# 5. 启动
|
||||||
|
role-mio chat
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
### 1. API Key 管理
|
||||||
|
|
||||||
|
- 用户在设置页生成长期 API Key(格式:`eak_` + 32 位随机 hex)
|
||||||
|
- API Key 列表显示:名称、创建时间、最后使用时间、Key(脱敏显示)
|
||||||
|
- 支持删除 API Key
|
||||||
|
- API Key 存储在数据库中,使用 bcrypt 哈希存储(只明文显示一次)
|
||||||
|
|
||||||
|
### 2. Hermes 配置 API
|
||||||
|
|
||||||
|
两个端点,均需 API Key 认证:
|
||||||
|
|
||||||
|
- `GET /api/hermes/roles/:id/SOUL.md` — 返回角色的 SOUL.md 内容(text/plain)
|
||||||
|
- `GET /api/hermes/roles/:id/config.yaml` — 返回适配后的 Hermes config.yaml(text/plain)
|
||||||
|
|
||||||
|
认证方式:`Authorization: Bearer eak_xxxxx`
|
||||||
|
|
||||||
|
权限:API Key 所属用户只能拉取自己创建的角色。
|
||||||
|
|
||||||
|
### 3. config.yaml 格式适配
|
||||||
|
|
||||||
|
EternalAI 数据库中的角色数据 → Hermes config.yaml 格式:
|
||||||
|
|
||||||
|
| EternalAI 字段 | Hermes config.yaml 字段 |
|
||||||
|
|---|---|
|
||||||
|
| `role.model` | `model:` |
|
||||||
|
| `role.temperature` | `temperature:` |
|
||||||
|
| `role.maxTokens` | `max_tokens:` |
|
||||||
|
| `role.enableMemory` | `memory.enabled:` |
|
||||||
|
| `role.enableTools` | `tools.enabled:` |
|
||||||
|
|
||||||
|
### 4. Web UI 部署指南
|
||||||
|
|
||||||
|
角色管理页每个角色卡片增加"Hermes 部署"区域:
|
||||||
|
- 显示完整的 curl 命令模板(自动填入服务器地址、角色 ID、API Key)
|
||||||
|
- 操作步骤说明
|
||||||
|
- 复制按钮
|
||||||
|
|
||||||
|
## 非功能需求
|
||||||
|
|
||||||
|
- API Key 使用 bcrypt 哈希存储,明文只在生成时显示一次
|
||||||
|
- Hermes API 端点返回 `text/plain` 格式,适合 curl 直接输出到文件
|
||||||
|
- config.yaml 适配在后端完成,用户拉取到的文件可直接使用
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,332 @@
|
||||||
|
# EternalAI 部署指南
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
用户浏览器 → Nginx (80/443) → Node.js Express (3001) → PostgreSQL (5432)
|
||||||
|
↓
|
||||||
|
PM2 进程管理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `ecosystem.config.js` | PM2 进程配置 |
|
||||||
|
| `.env.example` | 环境变量模板 |
|
||||||
|
| `deploy/setup-server.sh` | 首次服务器初始化脚本 |
|
||||||
|
| `deploy/deploy.sh` | 每次部署脚本 |
|
||||||
|
| `deploy/nginx.conf` | Nginx 反向代理配置模板 |
|
||||||
|
| `.gitea/workflows/deploy.yml` | Gitea Actions CI/CD 工作流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、首次部署(全量部署)
|
||||||
|
|
||||||
|
### 1.1 准备服务器
|
||||||
|
|
||||||
|
在目标服务器上执行以下操作。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆代码仓库
|
||||||
|
cd /opt # 或你选择的部署目录
|
||||||
|
git clone http://gitea.fischerai.cn/chigulong/eternalai.git
|
||||||
|
cd eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 运行服务器初始化脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/setup-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
此脚本会自动完成:
|
||||||
|
- 检测并安装 Node.js 20 LTS(如缺失)
|
||||||
|
- 检测并安装 PostgreSQL 15(如缺失)
|
||||||
|
- 检测并安装 PM2(如缺失)
|
||||||
|
- 检测并安装 Nginx(如缺失)
|
||||||
|
- 创建数据库 `eternalai` 和用户
|
||||||
|
- 自动生成 `.env` 文件(含随机 JWT 密钥和数据库密码)
|
||||||
|
- 安装 npm 依赖
|
||||||
|
- 生成 Prisma Client
|
||||||
|
- 推送数据库 Schema
|
||||||
|
- 启动 PM2 进程并配置开机自启
|
||||||
|
|
||||||
|
**自定义数据库配置**(可选):
|
||||||
|
```bash
|
||||||
|
DB_NAME=mydb DB_USER=myuser DB_PASS=mypassword bash deploy/setup-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本完成后会输出数据库密码,请妥善保存。
|
||||||
|
|
||||||
|
### 1.3 配置 Nginx 反向代理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制 Nginx 配置
|
||||||
|
sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||||
|
sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# 编辑配置,替换 YOUR_DOMAIN 为实际域名
|
||||||
|
sudo nano /etc/nginx/sites-enabled/eternalai
|
||||||
|
# 将所有 YOUR_DOMAIN 替换为你的域名,如 eternalai.example.com
|
||||||
|
|
||||||
|
# 测试配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 重载 Nginx
|
||||||
|
sudo nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 配置 HTTPS(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Certbot
|
||||||
|
sudo apt-get install -y certbot python3-certbot-nginx # Ubuntu/Debian
|
||||||
|
# 或
|
||||||
|
sudo yum install -y certbot python3-certbot-nginx # CentOS/RHEL
|
||||||
|
|
||||||
|
# 自动获取并配置 SSL 证书
|
||||||
|
sudo certbot --nginx -d YOUR_DOMAIN
|
||||||
|
|
||||||
|
# 证书自动续期(Certbot 会自动配置 cron)
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 验证部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 PM2 进程状态
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 查看应用日志
|
||||||
|
pm2 logs eternalai
|
||||||
|
|
||||||
|
# 测试本地访问
|
||||||
|
curl http://localhost:3001
|
||||||
|
|
||||||
|
# 测试 Nginx 代理访问
|
||||||
|
curl http://YOUR_DOMAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、CI/CD 自动部署(推送即部署)
|
||||||
|
|
||||||
|
### 2.1 配置 Gitea Secrets
|
||||||
|
|
||||||
|
在 Gitea 仓库设置中添加 Secret:
|
||||||
|
|
||||||
|
1. 进入仓库 → Settings → Actions → Secrets
|
||||||
|
2. 添加 `DATABASE_URL`,值为 `.env` 文件中的 `DATABASE_URL`
|
||||||
|
|
||||||
|
### 2.2 配置 Gitea Runner
|
||||||
|
|
||||||
|
确保已注册 self-hosted runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在服务器上注册 runner(如尚未注册)
|
||||||
|
# 参考 Gitea 官方文档: https://docs.gitea.com/usage/actions/quickstart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 自动部署流程
|
||||||
|
|
||||||
|
每次推送代码到 `master` 分支时,Gitea Actions 会自动:
|
||||||
|
|
||||||
|
1. 拉取最新代码
|
||||||
|
2. 安装 npm 依赖
|
||||||
|
3. 生成 Prisma Client
|
||||||
|
4. 推送数据库 Schema(`prisma db push`)
|
||||||
|
5. 重启 PM2 进程(`pm2 reload`)
|
||||||
|
6. 健康检查(等待 HTTP 200)
|
||||||
|
7. 重载 Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送代码触发自动部署
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 手动触发部署
|
||||||
|
|
||||||
|
在 Gitea 仓库 → Actions 页面,可手动触发 `Deploy EternalAI` 工作流。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、手动部署(不使用 CI/CD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH 登录服务器
|
||||||
|
ssh user@your-server
|
||||||
|
|
||||||
|
# 进入应用目录
|
||||||
|
cd /opt/eternalai
|
||||||
|
|
||||||
|
# 运行部署脚本
|
||||||
|
bash deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署脚本会自动完成:拉取代码 → 安装依赖 → 数据库迁移 → 重启 PM2 → 健康检查。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、常用运维命令
|
||||||
|
|
||||||
|
### PM2 进程管理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 status # 查看进程状态
|
||||||
|
pm2 logs eternalai # 实时查看日志
|
||||||
|
pm2 logs eternalai --lines 100 # 查看最近 100 行日志
|
||||||
|
pm2 restart eternalai # 重启进程
|
||||||
|
pm2 reload eternalai # 零停机重载
|
||||||
|
pm2 stop eternalai # 停止进程
|
||||||
|
pm2 delete eternalai # 删除进程
|
||||||
|
pm2 monit # 监控面板
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送 Schema 变更
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# 打开 Prisma Studio(数据库可视化管理)
|
||||||
|
npx prisma studio
|
||||||
|
|
||||||
|
# 连接 PostgreSQL
|
||||||
|
psql -U eternalai -d eternalai -h localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 操作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t # 测试配置
|
||||||
|
sudo nginx -s reload # 重载配置
|
||||||
|
sudo systemctl status nginx # 查看状态
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log # 查看错误日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 应用日志
|
||||||
|
tail -f logs/out.log # 标准输出
|
||||||
|
tail -f logs/err.log # 错误输出
|
||||||
|
|
||||||
|
# Nginx 日志
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_access.log
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||||
|
|
||||||
|
# PostgreSQL 日志
|
||||||
|
sudo tail -f /var/log/postgresql/postgresql-15-main.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、更新部署
|
||||||
|
|
||||||
|
### 5.1 日常更新(代码变更)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方法一:推送代码触发 CI/CD(推荐)
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# 方法二:手动部署
|
||||||
|
bash deploy/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 数据库 Schema 变更
|
||||||
|
|
||||||
|
修改 `prisma/schema.prisma` 后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送到 master,CI/CD 会自动执行 prisma db push
|
||||||
|
git add prisma/schema.prisma
|
||||||
|
git commit -m "feat: update schema"
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# 或手动执行
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 环境变量变更
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编辑 .env 文件
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 重启应用使配置生效
|
||||||
|
pm2 reload eternalai --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、回滚
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看部署历史
|
||||||
|
git log --oneline -10
|
||||||
|
|
||||||
|
# 回滚到指定版本
|
||||||
|
git checkout <commit-hash>
|
||||||
|
npm install
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
|
||||||
|
# 或回滚到上一个版本
|
||||||
|
git checkout HEAD~1
|
||||||
|
npm install
|
||||||
|
npx prisma db push
|
||||||
|
pm2 reload eternalai
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、故障排查
|
||||||
|
|
||||||
|
### 应用无法启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看错误日志
|
||||||
|
pm2 logs eternalai --err --lines 50
|
||||||
|
|
||||||
|
# 常见原因:
|
||||||
|
# 1. .env 文件缺失 → 运行 deploy/setup-server.sh
|
||||||
|
# 2. 数据库连接失败 → 检查 DATABASE_URL 和 PostgreSQL 服务
|
||||||
|
# 3. 端口被占用 → 检查 PORT 环境变量
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库连接失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 PostgreSQL 服务
|
||||||
|
sudo systemctl status postgresql
|
||||||
|
|
||||||
|
# 测试连接
|
||||||
|
psql -U eternalai -d eternalai -h localhost -W
|
||||||
|
|
||||||
|
# 检查 pg_hba.conf 认证配置
|
||||||
|
sudo cat /etc/postgresql/15/main/pg_hba.conf | grep -v '^#'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 502 Bad Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查 Node.js 进程是否运行
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 检查端口
|
||||||
|
curl http://localhost:3001
|
||||||
|
|
||||||
|
# 检查 Nginx 错误日志
|
||||||
|
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM2 进程未开机自启
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重新配置开机自启
|
||||||
|
pm2 startup systemd
|
||||||
|
# 执行输出的 sudo 命令
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,375 @@
|
||||||
|
# Eternal AI 完整功能与链接路由规划
|
||||||
|
|
||||||
|
**Type:** feat
|
||||||
|
**Date:** 2026-06-20
|
||||||
|
**Origin:** `Eternal_AI_PRD_v1.docx`(微信小程序 PRD v1.0)
|
||||||
|
**Target repo:** EternalAI
|
||||||
|
**Status:** active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
本规划依据 `Eternal_AI_PRD_v1.docx` 对现有 HTML5 落地页进行补全,核心目标是:
|
||||||
|
|
||||||
|
1. 让首页四个入口(我的 XXX、蒸馏前任、关于 Eternal AI、申请入驻)分别指向 PRD 定义的独立页面,而不是全部打开同一个人设蒸馏表单。
|
||||||
|
2. 补齐 PRD 中缺失的 4 个页面:角色库页(P2)、角色详情页(P3)、关于 Eternal AI 页(P5)、创作者入驻页(P6)。
|
||||||
|
3. 将现有的人设蒸馏表单重新定位到「创作者管理中心 - 角色编辑」(P7),保留 Soul.md / config.yaml 生成功能。
|
||||||
|
4. 增加底部 tabBar(首页 / 蒸馏前任 / 我的专属创作者),与 PRD 导航结构一致。
|
||||||
|
|
||||||
|
当前已实现的页面:首页(P1)、登录/注册视图、蒸馏前任服务页(P4)、人设蒸馏表单(待重新定位)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem Frame
|
||||||
|
|
||||||
|
现有实现把三个完全不同的用户意图塞进了同一个表单:
|
||||||
|
|
||||||
|
- **普通用户想找角色库** → 打开了登录/注册(已修正,但登录后无角色库可去)。
|
||||||
|
- **普通用户想购买蒸馏前任服务** → 打开了正确页面(P4)。
|
||||||
|
- **创作者想入驻** → 打开了人设蒸馏表单(错误:PRD 要求 P6 仅展示合作说明与微信二维码,不填表单)。
|
||||||
|
- **关于 Eternal AI** → 仅有一个空锚点 `#about`,无实际页面。
|
||||||
|
|
||||||
|
此外,PRD 定义的角色库(P2)、角色详情(P3)、创作者管理中心(P7)均未实现,导致用户登录后没有内容,创作者也没有入口管理角色和收入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Requirements Traceability
|
||||||
|
|
||||||
|
| PRD 页面 | 访问权限 | 入口 | 当前状态 | 规划动作 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P1 首页 | 所有人 | 打开站点 | 已实现 | 保持,调整链接 |
|
||||||
|
| P2 角色库页 | 已登录 + 已绑定创作者 | 首页「我的 XXX」 | 缺失 | 新增 |
|
||||||
|
| P3 角色详情页 | 已登录用户 | P2 角色卡片 | 缺失 | 新增 |
|
||||||
|
| P4 蒸馏前任页 | 所有人 | 首页「蒸馏前任」/ tabBar | 已实现 | 保持,补充底部 tabBar 入口 |
|
||||||
|
| P5 关于 Eternal AI 页 | 所有人 | 首页底部「关于 Eternal AI」 | 缺失 | 新增 |
|
||||||
|
| P6 创作者入驻页 | 所有人 | 首页底部「申请入驻」 | 错误指向 creator 表单 | 改为微信联系说明页 |
|
||||||
|
| P7 创作者管理中心 | 创作者账号登录后 | tabBar「管理」 | 缺失 | 新增,复用现有 creator 表单作为「角色编辑」 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Link Rationality(首页入口调整说明)
|
||||||
|
|
||||||
|
### 4.1 我的 XXX → 登录 → 角色库(P2)
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 中「我的 XXX」是角色库入口,而角色库需要登录后才能访问。
|
||||||
|
- 未登录用户点击后应先进入登录/注册;登录后根据是否绑定创作者决定展示内容。
|
||||||
|
- 登录态由前端本地模拟(localStorage 标记),后续由后端接管。
|
||||||
|
|
||||||
|
**状态分支:**
|
||||||
|
- 未登录 → 登录/注册视图。
|
||||||
|
- 已登录 + 已绑定创作者 → P2 角色库(显示该创作者上架的角色)。
|
||||||
|
- 已登录 + 未绑定创作者 → P2 角色库占位态,提示「你还没有绑定专属创作者」。
|
||||||
|
|
||||||
|
### 4.2 蒸馏前任 → P4 蒸馏前任服务页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- 与 PRD 一致,面向情感类用户的自营服务。
|
||||||
|
- 保持现有 P4 页面结构:服务介绍、流程、价格、下单、客服微信、分润说明。
|
||||||
|
|
||||||
|
### 4.3 关于 Eternal AI → P5 关于页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 明确这是建立女性用户信任的页面,需要展示平台简介、连接方式说明、FAQ(安全/隐私/账号/情感)。
|
||||||
|
- 当前空锚点 `#about` 无法承载任何信息,必须新建独立视图。
|
||||||
|
|
||||||
|
### 4.4 我是创作者,申请入驻 → P6 创作者入驻页
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 明确 P6「不做表单提交,直接走微信私聊沟通」。
|
||||||
|
- 现有的人设蒸馏表单是创作者入驻后的生产工具,不是入驻申请本身。
|
||||||
|
- 因此 footer 入口应改为展示合作模式、分润比例、负责人微信二维码的引导页。
|
||||||
|
|
||||||
|
### 4.5 底部 tabBar
|
||||||
|
|
||||||
|
**为什么这样设计:**
|
||||||
|
- PRD 底部导航为「首页 / 蒸馏前任 / 我的专属创作者」。
|
||||||
|
- 在 HTML5 站点中用固定底部 tabBar 模拟小程序导航,让 P4 和 P2/P7 都有全局入口。
|
||||||
|
- tabBar 在首页、蒸馏前任、角色库/创作者中心之间切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Units
|
||||||
|
|
||||||
|
### U1. 调整「我的 XXX」链路:未登录进登录页,已登录进角色库
|
||||||
|
|
||||||
|
**Goal:** 让首页「我的 XXX」卡片根据登录态正确分流。
|
||||||
|
|
||||||
|
**Requirements:** PRD P1 板块三、P2 条件逻辑。
|
||||||
|
|
||||||
|
**Dependencies:** U2(角色库页必须先存在)。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 在 `app.js` 增加 `authState` 对象:`{ isLoggedIn: bool, boundCreator: object|null }`。
|
||||||
|
- 点击「我的 XXX」时:
|
||||||
|
- 未登录 → `showView('auth')`,登录成功后写入 `authState` 并跳转 P2。
|
||||||
|
- 已登录 → 直接 `showView('role-library')`。
|
||||||
|
- 登录/注册提交不再 `alert`,而是更新状态并跳转。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 未登录点击「我的 XXX」→ 显示登录页。
|
||||||
|
- 登录成功后点击「我的 XXX」→ 显示角色库。
|
||||||
|
- 登录后 `boundCreator` 为空 → 角色库显示「未绑定创作者」提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U2. 新增角色库页(P2)
|
||||||
|
|
||||||
|
**Goal:** 展示创作者上架的所有 AI 角色。
|
||||||
|
|
||||||
|
**Requirements:** PRD P2。
|
||||||
|
|
||||||
|
**Dependencies:** U1。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#role-library` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--role-library`,顶部显示创作者自定义名称(如「云朵的后宫」)。
|
||||||
|
- 角色卡片列表:头像、名字、简短简介、价格。
|
||||||
|
- 点击卡片 → P3 角色详情。
|
||||||
|
- 空态:未绑定创作者时显示提示文案。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 角色列表按 PRD 字段渲染。
|
||||||
|
- 点击卡片正确跳转角色详情。
|
||||||
|
- 未绑定创作者时显示空态提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U3. 新增角色详情页(P3)
|
||||||
|
|
||||||
|
**Goal:** 展示单个角色详情并模拟订阅/二维码流程。
|
||||||
|
|
||||||
|
**Requirements:** PRD P3。
|
||||||
|
|
||||||
|
**Dependencies:** U2。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#role-detail` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--role-detail`,包含:角色大图、名字、详细介绍、价格、付款按钮。
|
||||||
|
- 点击「付款」后切换到已付款态:显示微信专属二维码、可下载头像、引导文案。
|
||||||
|
- 由于无真实支付接口,付款按钮先切换本地状态并展示二维码占位图。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 付款前态显示价格与按钮。
|
||||||
|
- 点击付款后切换到已付款态,显示二维码和头像下载。
|
||||||
|
- 返回按钮回到角色库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U4. 新增关于 Eternal AI 页(P5)
|
||||||
|
|
||||||
|
**Goal:** 建立用户信任,解答安全、隐私、账号、情感类常见问题。
|
||||||
|
|
||||||
|
**Requirements:** PRD P5。
|
||||||
|
|
||||||
|
**Dependencies:** 无。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#about` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--about`,结构:平台简介、连接方式说明(Claw / iLink)、FAQ 折叠面板。
|
||||||
|
- FAQ 分类:安全类、隐私类、账号类、情感类。
|
||||||
|
- 首页底部「关于 Eternal AI」改为 `data-action="open-about"`。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 点击 footer「关于 Eternal AI」打开 P5。
|
||||||
|
- FAQ 可展开/收起。
|
||||||
|
- 返回按钮回到首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U5. 重构创作者入驻页(P6)为微信联系引导
|
||||||
|
|
||||||
|
**Goal:** 将 footer「申请入驻」从 creator 表单改为 PRD 定义的入驻说明页。
|
||||||
|
|
||||||
|
**Requirements:** PRD P6。
|
||||||
|
|
||||||
|
**Dependencies:** 无。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#creator-onboarding` view,或复用/替换原 footer 目标)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--creator-onboarding`:入驻说明、合作模式(20% 平台 + 80% 创作者)、负责人微信二维码/微信号。
|
||||||
|
- 不提交表单,仅展示联系方式。
|
||||||
|
- footer「我是创作者,申请入驻」改为 `data-action="open-creator-onboarding"`。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 点击 footer「申请入驻」打开 P6 说明页。
|
||||||
|
- 页面包含微信二维码和分润说明。
|
||||||
|
- 返回按钮回到首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U6. 将现有人设蒸馏表单重新定位为「创作者管理中心 - 角色编辑」
|
||||||
|
|
||||||
|
**Goal:** 保留 Soul.md / config.yaml 生成功能,但仅作为创作者后台工具。
|
||||||
|
|
||||||
|
**Requirements:** PRD P7 Tab 1 角色管理页;同时满足用户原始需求(生成 Hermes agent 配置文件)。
|
||||||
|
|
||||||
|
**Dependencies:** U5。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 当前 `#creator` view 从「landing footer 入口」改为「creator center 内部编辑页」。
|
||||||
|
- 入口文案调整:header 从「Eternal AI / 人设蒸馏器」改为「角色编辑 / 生成配置文件」。
|
||||||
|
- 保留 4 步表单和下载逻辑。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 从创作者管理中心点击角色进入编辑页时,表单正常显示。
|
||||||
|
- 提交后仍能生成并下载 Soul.md + config.yaml。
|
||||||
|
- 返回按钮回到创作者管理中心的角色列表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U7. 新增创作者管理中心(P7)
|
||||||
|
|
||||||
|
**Goal:** 为创作者提供角色管理、收入、账户设置入口。
|
||||||
|
|
||||||
|
**Requirements:** PRD P7。
|
||||||
|
|
||||||
|
**Dependencies:** U5, U6。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`(新增 `#creator-center` view)
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 新增 `view--creator-center`,内部三个 tab:我的角色 / 收入 / 我的。
|
||||||
|
- **我的角色:** 角色列表(头像、名字、状态),点击角色进入 U6 的编辑页,新增角色也进入同一表单。
|
||||||
|
- **收入:** 可提现余额、流水明细、提现申请表单(收款方式 + 金额)。
|
||||||
|
- **我的:** 创作者名字、自定义角色库名称(即首页「我的 XXX」显示文字)、退出登录。
|
||||||
|
- 该中心仅在 `authState` 标记为创作者时从 tabBar「管理」进入;普通用户该 tab 指向角色库或提示申请创作者。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 三个 tab 可切换。
|
||||||
|
- 点击角色进入编辑表单。
|
||||||
|
- 自定义角色库名称保存后,首页「我的 XXX」同步更新。
|
||||||
|
- 收入页显示余额与流水,提现表单可提交(前端模拟)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U8. 新增底部 tabBar 导航
|
||||||
|
|
||||||
|
**Goal:** 模拟 PRD 的小程序底部导航:首页 / 蒸馏前任 / 我的专属创作者。
|
||||||
|
|
||||||
|
**Requirements:** PRD P1 底部导航栏。
|
||||||
|
|
||||||
|
**Dependencies:** U2, U4, U7。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `index.html`
|
||||||
|
- `styles.css`
|
||||||
|
- `app.js`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 在 `index.html` 底部新增 `.tab-bar` 固定元素。
|
||||||
|
- 三个 tab:首页 → P1;蒸馏前任 → P4;我的专属创作者 → 未登录进登录页,已登录普通用户进 P2,已登录创作者进 U7 创作者管理中心。
|
||||||
|
- tabBar 在当前页面对应 tab 上高亮。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 三个 tab 正确切换视图。
|
||||||
|
- 高亮状态与当前视图一致。
|
||||||
|
- 未登录时点击第三个 tab 先进登录页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### U9. 统一视图路由与状态管理
|
||||||
|
|
||||||
|
**Goal:** 让多视图切换更可靠,便于后续接入真实后端。
|
||||||
|
|
||||||
|
**Requirements:** 全局路由一致性。
|
||||||
|
|
||||||
|
**Dependencies:** U1–U8。
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app.js`
|
||||||
|
- `index.html`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- 维护 `views` 字典,所有视图统一 `showView(viewId)` 切换。
|
||||||
|
- `authState` 和 `creatorState` 集中到顶部,提供 `login()`, `logout()`, `bindCreator()` 方法。
|
||||||
|
- 每次视图切换时滚动到顶部,并更新 tabBar 高亮。
|
||||||
|
- 保留 URL hash 与当前视图同步(可选,便于刷新后定位)。
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- 任意返回按钮都能回到正确上一页或首页。
|
||||||
|
- 刷新页面后仍保持登录态(localStorage)。
|
||||||
|
- 视图切换无 JavaScript 报错。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key Technical Decisions
|
||||||
|
|
||||||
|
| 决策 | 选项 | 选择 | 理由 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 登录态存储 | localStorage / sessionStorage / 仅内存 | localStorage | 单页应用刷新后保持状态,后续后端接管时替换为 token 即可。 |
|
||||||
|
| 支付流程 | 真实支付接口 / 前端模拟 | 前端模拟 | PRD 附录列明「自动支付接口暂不开发」,一期仅做 UI 状态切换。 |
|
||||||
|
| 角色数据 | 硬编码 mock / localStorage / 后端 | 硬编码 mock + 可扩展结构 | 一期先展示前端,数据结构按后端接口预留字段。 |
|
||||||
|
| 创作者表单定位 | 删除 / 保留在创作者中心 | 保留在创作者中心 | 满足用户原始需求:生成 Soul.md 与 config.yaml。 |
|
||||||
|
| tabBar 实现 | 固定底部栏 / 无 tabBar | 固定底部栏 | 与 PRD 一致,也弥补 HTML5 缺少小程序原生导航的问题。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
- 首页四个入口的路由修正。
|
||||||
|
- P2、P3、P5、P6、P7 的页面结构与交互。
|
||||||
|
- 底部 tabBar。
|
||||||
|
- 前端模拟的登录态、创作者态、支付态。
|
||||||
|
|
||||||
|
### Deferred to Follow-Up Work
|
||||||
|
- 真实后端 API 对接(登录、支付、角色 CRUD、收入流水)。
|
||||||
|
- 文件上传(蒸馏前任聊天记录),PRD 已明确一期走微信人工指导。
|
||||||
|
- 自动提现审核与转账,PRD 已明确一期手动处理。
|
||||||
|
- 运营后台。
|
||||||
|
- 创作者页面颜色主题自定义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Dependencies
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|---|---|---|
|
||||||
|
| PRD 面向微信小程序,而当前是 HTML5 站点,部分交互(如微信扫码、小程序 tabBar)需要重新设计。 | 中 | 用 HTML5 可实现的等价交互替代:tabBar 用固定底部栏、扫码用二维码占位图、微信支付用模拟按钮。 |
|
||||||
|
| 创作者管理中心把现有人设蒸馏表单作为角色编辑页,表单字段可能与 PRD P7 的「角色管理页」字段不完全一致。 | 低 | 在 U6 中明确表单就是角色编辑工具,PRD P7 的其他字段(价格、用量、二维码)在角色列表中展示。 |
|
||||||
|
| 缺少真实后端,登录/支付状态容易在刷新或切换设备时丢失。 | 低 | 一期用 localStorage,并在计划中标注后续对接点。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Open Questions
|
||||||
|
|
||||||
|
1. 创作者入驻(P6)的负责人微信号和二维码是否已确定?需要真实素材占位还是先用示例图?
|
||||||
|
2. 角色库页(P2)的示例角色数据是否需要用户提供,还是先用 2–3 个虚构角色填充?
|
||||||
|
3. 首页「我的 XXX」中的 XXX 默认文案是否保留「我的 [XXX]」还是给一个默认占位如「我的角色库」?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sources & Research
|
||||||
|
|
||||||
|
- `Eternal_AI_PRD_v1.docx` — 产品需求文档,定义 7 个页面及交互逻辑。
|
||||||
|
- 当前代码:`index.html`、`app.js`、`styles.css` — 现有单页应用结构与视图切换方式。
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('认证流程', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册新用户成功', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// 点击「我的 XXX」卡片进入 auth 视图(未登录时)
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 切换到注册 tab
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
// 填写注册表单
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', testData.users.newUser.account);
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', testData.users.newUser.password);
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.newUser.password);
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 注册成功后自动成为创作者,应跳转到创作者中心
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重复注册同一账号失败', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.existing.password);
|
||||||
|
|
||||||
|
// 监听 dialog(错误信息通过 alert 弹出)
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('已注册');
|
||||||
|
await dialog.accept();
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录已有用户成功', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 默认在登录 tab
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 登录成功后(existing 是创作者)应跳转到创作者中心
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('错误密码登录失败', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', 'wrongpassword');
|
||||||
|
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('账号或密码错误');
|
||||||
|
await dialog.accept();
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('注册时密码不一致提示', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||||
|
// 等待注册表单的 fadeIn 动画完成
|
||||||
|
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
await page.fill('[data-form="register"] [name="account"]', 'mismatch_user');
|
||||||
|
await page.fill('[data-form="register"] [name="password"]', 'password1');
|
||||||
|
await page.fill('[data-form="register"] [name="confirmPassword"]', 'password2');
|
||||||
|
|
||||||
|
// 密码不一致通过原生 setCustomValidity + reportValidity 提示(非 alert)
|
||||||
|
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||||
|
|
||||||
|
// 验证 confirmPassword 字段的 validationMessage 包含「不一致」
|
||||||
|
const validationMessage = await page
|
||||||
|
.locator('[data-form="register"] [name="confirmPassword"]')
|
||||||
|
.evaluate((el) => el.validationMessage);
|
||||||
|
expect(validationMessage).toContain('不一致');
|
||||||
|
|
||||||
|
// 应仍停留在 auth 视图
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登出后回到首页', async ({ page }) => {
|
||||||
|
// 先登录
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 切换到设置 tab(登出按钮在设置面板)
|
||||||
|
await page.locator('[data-center-tab="settings"]').click();
|
||||||
|
await expect(page.locator('#center-settings')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击登出
|
||||||
|
await page.locator('[data-action="logout"]').click();
|
||||||
|
|
||||||
|
// 应回到首页
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录态持久化(刷新页面后仍登录)', async ({ page }) => {
|
||||||
|
// 先登录
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 刷新页面
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// 首页应可见,且卡片按钮文案应变化(不再是「登录 / 注册」)
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
await expect(page.locator('#characters-btn')).not.toHaveText(/登录/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('创作者中心与角色发布/编辑', () => {
|
||||||
|
let existingUser;
|
||||||
|
let existingRole;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
existingUser = await prisma.user.findUnique({
|
||||||
|
where: { account: testData.users.existing.account },
|
||||||
|
});
|
||||||
|
|
||||||
|
existingRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '已有角色',
|
||||||
|
gender: 'female',
|
||||||
|
personality: '温柔体贴',
|
||||||
|
background: '来自南方',
|
||||||
|
speechStyle: '轻声细语',
|
||||||
|
greeting: '你好~',
|
||||||
|
desc: '温柔体贴的角色',
|
||||||
|
price: 19.9,
|
||||||
|
status: 'running',
|
||||||
|
temperature: 0.8,
|
||||||
|
maxTokens: 2048,
|
||||||
|
enableMemory: true,
|
||||||
|
enableTools: false,
|
||||||
|
model: 'gpt-4o',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAsExisting(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('创作者中心显示三个 tab', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await expect(page.locator('[data-center-tab="roles"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-center-tab="income"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-center-tab="settings"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色 tab 显示已有角色', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
// 默认在 roles tab
|
||||||
|
await expect(page.locator('#creator-role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||||
|
await expect(page.locator('#creator-role-list .role-card__name')).toHaveText('已有角色');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收入 tab 显示余额和流水', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-center-tab="income"]').click();
|
||||||
|
// income 面板可见(#center-income 带 active class)
|
||||||
|
await expect(page.locator('#center-income')).toBeVisible();
|
||||||
|
// 余额元素可见
|
||||||
|
await expect(page.locator('#income-balance')).toBeVisible({ timeout: 5000 });
|
||||||
|
// 流水列表容器存在
|
||||||
|
await expect(page.locator('#income-list')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置 tab 可保存昵称和库名', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-center-tab="settings"]').click();
|
||||||
|
await expect(page.locator('#center-settings')).toBeVisible();
|
||||||
|
|
||||||
|
// 修改设置
|
||||||
|
await page.fill('#settings-form [name="creatorName"]', '新昵称');
|
||||||
|
await page.fill('#settings-form [name="libraryName"]', '新库名');
|
||||||
|
|
||||||
|
// 保存成功会弹 alert「设置已保存」
|
||||||
|
const dialogPromise = page.waitForEvent('dialog');
|
||||||
|
await page.locator('#settings-form button[type="submit"]').click();
|
||||||
|
const dialog = await dialogPromise;
|
||||||
|
expect(dialog.message()).toContain('保存');
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建角色完整流程(4步表单)', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
|
||||||
|
// 点击新建角色
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// Step 1: 基础身份(agentId 必填,pattern [a-zA-Z0-9_]+;displayName 必填)
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_test_role');
|
||||||
|
await page.fill('#character-form [name="displayName"]', testData.role.displayName);
|
||||||
|
await page.selectOption('#character-form [name="gender"]', testData.role.gender);
|
||||||
|
await page.fill('#character-form [name="age"]', testData.role.age);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 2: 灵魂设定(background / personality / speechStyle 必填)
|
||||||
|
await page.fill('#character-form [name="background"]', testData.role.background);
|
||||||
|
await page.fill('#character-form [name="personality"]', testData.role.personality);
|
||||||
|
await page.fill('#character-form [name="speechStyle"]', testData.role.speechStyle);
|
||||||
|
await page.fill('#character-form [name="likes"]', testData.role.likes);
|
||||||
|
await page.fill('#character-form [name="dislikes"]', testData.role.dislikes);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 3: 关系与记忆(greeting 必填)
|
||||||
|
await page.fill('#character-form [name="relationship"]', testData.role.relationship);
|
||||||
|
await page.fill('#character-form [name="memories"]', testData.role.memories);
|
||||||
|
await page.fill('#character-form [name="secrets"]', testData.role.secrets);
|
||||||
|
await page.fill('#character-form [name="greeting"]', testData.role.greeting);
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 4: 运行配置(model/temperature/maxTokens 有默认值;无 price 字段)
|
||||||
|
// 捕获可能的错误弹窗
|
||||||
|
let dialogMessage = '';
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||||
|
|
||||||
|
// 应显示生成结果面板
|
||||||
|
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 验证 Soul.md 内容(单个 #preview-code 元素,默认显示 soul)
|
||||||
|
await expect(page.locator('#preview-code')).toBeVisible();
|
||||||
|
const soulContent = await page.locator('#preview-code').textContent();
|
||||||
|
expect(soulContent).toContain(testData.role.displayName);
|
||||||
|
// generateSoulMd 会把 personality 按 [,,] 拆分成 " | " 连接的标签
|
||||||
|
const personalityTag = testData.role.personality.split(/[,,]/)[0].trim();
|
||||||
|
expect(soulContent).toContain(personalityTag);
|
||||||
|
|
||||||
|
// 切换到 config.yaml 预览(preview-tabs 内的 [data-tab="config"])
|
||||||
|
await page.locator('.preview-tabs [data-tab="config"]').click();
|
||||||
|
const configContent = await page.locator('#preview-code').textContent();
|
||||||
|
expect(configContent).toContain('model:');
|
||||||
|
expect(configContent).toContain('temperature:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('新建角色后数据库有记录', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// Step 1: 填写最小必填字段
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_db_role');
|
||||||
|
await page.fill('#character-form [name="displayName"]', 'DB验证角色');
|
||||||
|
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
await page.fill('#character-form [name="personality"]', '测试性格');
|
||||||
|
await page.fill('#character-form [name="background"]', '测试背景');
|
||||||
|
await page.fill('#character-form [name="speechStyle"]', '测试风格');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
await page.fill('#character-form [name="greeting"]', '测试问候');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// Step 4: 直接发布(无 price 字段,使用默认值)
|
||||||
|
let dialogMessage = '';
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
dialogMessage = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
});
|
||||||
|
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||||
|
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
// 验证数据库
|
||||||
|
const dbRole = await prisma.role.findFirst({
|
||||||
|
where: { displayName: 'DB验证角色' },
|
||||||
|
});
|
||||||
|
expect(dbRole).not.toBeNull();
|
||||||
|
expect(dbRole.personality).toBe('测试性格');
|
||||||
|
expect(dbRole.greeting).toBe('测试问候');
|
||||||
|
expect(dbRole.soulMd).not.toBeNull();
|
||||||
|
expect(dbRole.configYaml).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('编辑角色加载已有数据', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
|
||||||
|
// 等待角色列表加载,找到已 seeded 的角色(其他测试可能已创建额外角色)
|
||||||
|
await expect(page.locator('#creator-role-list .role-card')).not.toHaveCount(0, { timeout: 5000 });
|
||||||
|
// 定位到"已有角色"那张卡片的编辑按钮
|
||||||
|
const seededCard = page.locator('#creator-role-list .role-card', { hasText: '已有角色' }).first();
|
||||||
|
await expect(seededCard).toBeVisible({ timeout: 5000 });
|
||||||
|
await seededCard.locator('[data-action="edit-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 验证表单已预填(loadRoleForEdit 会拉取 /roles/:id/full 填充表单)
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('已有角色', { timeout: 5000 });
|
||||||
|
await expect(page.locator('#character-form [name="personality"]')).toHaveValue('温柔体贴');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('表单验证 - 必填字段为空时阻止提交', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 不填任何字段直接点下一步
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// 应仍停留在 step 0(agentId / displayName 必填,验证失败)
|
||||||
|
// step 0 仍 active => agentId 字段仍可见
|
||||||
|
await expect(page.locator('#character-form [name="agentId"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('步骤导航 - 上一步按钮', async ({ page }) => {
|
||||||
|
await loginAsExisting(page);
|
||||||
|
await page.locator('[data-action="new-role"]').click();
|
||||||
|
await expect(page.locator('#creator')).toBeVisible();
|
||||||
|
|
||||||
|
// 填写 step 1 并前进
|
||||||
|
await page.fill('#character-form [name="agentId"]', 'e2e_nav_test');
|
||||||
|
await page.fill('#character-form [name="displayName"]', '导航测试');
|
||||||
|
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||||
|
await page.locator('.form-step.active [data-action="next"]').click();
|
||||||
|
|
||||||
|
// 进入 step 2 后点上一步(data-action="prev",非 prev-step)
|
||||||
|
await expect(page.locator('#character-form [name="background"]')).toBeVisible();
|
||||||
|
await page.locator('.form-step.active [data-action="prev"]').click();
|
||||||
|
|
||||||
|
// 应回到 step 1,且数据保留
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||||
|
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('导航测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// 数据库清理 helper
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function cleanDatabase() {
|
||||||
|
await prisma.order.deleteMany();
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedExistingUser() {
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const testData = require('./test-data');
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: testData.users.existing.account,
|
||||||
|
password: bcrypt.hashSync(testData.users.existing.password, 10),
|
||||||
|
isCreator: true,
|
||||||
|
creatorName: testData.users.existing.account,
|
||||||
|
libraryName: 'E2E测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// 测试数据 fixtures
|
||||||
|
const testData = {
|
||||||
|
users: {
|
||||||
|
newUser: {
|
||||||
|
account: `e2e_${Date.now()}`,
|
||||||
|
password: 'Test123456',
|
||||||
|
},
|
||||||
|
existing: {
|
||||||
|
account: 'e2e_existing',
|
||||||
|
password: 'Test123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
displayName: 'E2E测试角色',
|
||||||
|
gender: 'female',
|
||||||
|
age: '25',
|
||||||
|
relationship: '女友',
|
||||||
|
personality: '温柔,体贴,善解人意',
|
||||||
|
background: '来自江南水乡的女孩,喜欢读书和绘画',
|
||||||
|
speechStyle: '轻声细语,偶尔带点小俏皮',
|
||||||
|
likes: '读书,绘画,猫咪',
|
||||||
|
dislikes: '吵闹,谎言',
|
||||||
|
memories: '一起看过的樱花,第一次约会',
|
||||||
|
secrets: '其实很怕打雷',
|
||||||
|
greeting: '你回来啦~今天过得怎么样?',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
temperature: '0.8',
|
||||||
|
maxTokens: '2048',
|
||||||
|
price: '29.9',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = testData;
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { cleanDatabase, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
test.describe('导航与可访问性', () => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首页显示两张卡片', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
// 两张卡片:open-characters 和 open-distill
|
||||||
|
const cards = page.locator('[data-action="open-characters"], [data-action="open-distill"]');
|
||||||
|
await expect(cards).toHaveCount(2, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('底部 tabBar 三个 tab 可见', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.tab-bar')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-home"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-distill"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-tab-action="tab-mine"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tabBar 切换视图', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 点击蒸馏前任 tab
|
||||||
|
await page.locator('[data-tab-action="tab-distill"]').click();
|
||||||
|
await expect(page.locator('#distill')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击首页 tab
|
||||||
|
await page.locator('[data-tab-action="tab-home"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击我的 tab(未登录应跳转到 auth)
|
||||||
|
await page.locator('[data-tab-action="tab-mine"]').click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('首页卡片点击进入对应视图', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 点击蒸馏前任卡片
|
||||||
|
await page.locator('[data-action="open-distill"]').first().click();
|
||||||
|
await expect(page.locator('#distill')).toBeVisible();
|
||||||
|
|
||||||
|
// 返回首页
|
||||||
|
await page.locator('[data-tab-action="tab-home"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击「我的 XXX」卡片(未登录进入 auth)
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关于页面可访问', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('创作者入驻页面可访问', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-onboarding"]').first().click();
|
||||||
|
await expect(page.locator('#onboarding')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FAQ 折叠展开', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击第一个 FAQ 按钮(.faq-q,非 .faq-item__question)
|
||||||
|
const faqButton = page.locator('.faq-q').first();
|
||||||
|
await faqButton.click();
|
||||||
|
|
||||||
|
// 验证 aria-expanded 变为 true
|
||||||
|
await expect(faqButton).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
// 再次点击折叠
|
||||||
|
await faqButton.click();
|
||||||
|
await expect(faqButton).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('返回按钮基于历史记录导航', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// 导航路径: landing → about → 返回
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(page.locator('#about')).toBeVisible();
|
||||||
|
|
||||||
|
// about 视图使用 data-action="back"
|
||||||
|
await page.locator('#about [data-action="back"]').click();
|
||||||
|
await expect(page.locator('#landing')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('跳过链接(a11y)存在且可用', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const skipLink = page.locator('.skip-link');
|
||||||
|
// 跳过链接应存在
|
||||||
|
await expect(skipLink).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tabBar 有正确的 ARIA 角色', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const tabbar = page.locator('.tab-bar');
|
||||||
|
await expect(tabbar).toHaveAttribute('role', 'tablist');
|
||||||
|
|
||||||
|
// tab-bar 内的按钮应有 role="tab"
|
||||||
|
const tabs = page.locator('.tab-bar [data-tab-action]');
|
||||||
|
const count = await tabs.count();
|
||||||
|
expect(count).toBe(3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
await expect(tabs.nth(i)).toHaveAttribute('role', 'tab');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('视图切换时 aria-live 播报', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// #sr-announce 是 aria-live 区域,role="status" aria-live="polite"
|
||||||
|
const liveRegion = page.locator('#sr-announce');
|
||||||
|
await expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
||||||
|
await expect(liveRegion).toHaveAttribute('role', 'status');
|
||||||
|
|
||||||
|
// 切换视图后 live region 应有内容
|
||||||
|
await page.locator('[data-action="open-about"]').first().click();
|
||||||
|
await expect(liveRegion).not.toHaveText('');
|
||||||
|
const liveText = await liveRegion.textContent();
|
||||||
|
expect(liveText.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色卡片支持键盘操作', async ({ page }) => {
|
||||||
|
// 创建非创作者用户 + 角色
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: 'e2e_keyboard_user',
|
||||||
|
password: bcrypt.hashSync('Test123456', 10),
|
||||||
|
isCreator: false,
|
||||||
|
libraryName: '键盘测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: user.id,
|
||||||
|
displayName: '键盘测试角色',
|
||||||
|
gender: 'female',
|
||||||
|
personality: '测试',
|
||||||
|
background: '测试',
|
||||||
|
speechStyle: '测试',
|
||||||
|
greeting: '测试',
|
||||||
|
desc: '键盘测试',
|
||||||
|
price: 9.9,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录非创作者(登录后自动进入角色库)
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', 'e2e_keyboard_user');
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', 'Test123456');
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 聚焦角色卡片(role-card 有 tabindex="0")
|
||||||
|
const roleCard = page.locator('#role-list .role-card').first();
|
||||||
|
await roleCard.focus();
|
||||||
|
|
||||||
|
// 按 Enter 应进入详情
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
await prisma.user.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('表单 label 与 input 关联', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
|
||||||
|
// 检查登录表单的 input 存在
|
||||||
|
const accountInput = page.locator('[data-form="login"] [name="account"]');
|
||||||
|
await expect(accountInput).toBeVisible();
|
||||||
|
|
||||||
|
// 检查是否有对应的 label(包裹式 label 包含账号相关文案)
|
||||||
|
const labelText = await page.locator('label').filter({ hasText: /账号|用户名|Account/i }).count();
|
||||||
|
expect(labelText).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('focus-visible 样式存在', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
// Tab 到第一个可聚焦元素
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
// 聚焦元素应有可见的焦点样式(:focus-visible 使用 outline)
|
||||||
|
const focused = await page.evaluate(() => {
|
||||||
|
const el = document.activeElement;
|
||||||
|
if (!el) return false;
|
||||||
|
const styles = window.getComputedStyle(el);
|
||||||
|
return styles.outlineStyle !== 'none' || styles.boxShadow !== 'none';
|
||||||
|
});
|
||||||
|
expect(focused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const testData = require('./fixtures/test-data');
|
||||||
|
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||||
|
|
||||||
|
// 非创作者用户(用于角色库测试)
|
||||||
|
const NON_CREATOR = {
|
||||||
|
account: 'e2e_noncreator',
|
||||||
|
password: 'Test123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('角色库与角色详情', () => {
|
||||||
|
let existingUser;
|
||||||
|
let testRole;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await seedExistingUser();
|
||||||
|
existingUser = await prisma.user.findUnique({
|
||||||
|
where: { account: testData.users.existing.account },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建非创作者用户(open-characters 对非创作者进入 role-library)
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: NON_CREATOR.account,
|
||||||
|
password: bcrypt.hashSync(NON_CREATOR.password, 10),
|
||||||
|
isCreator: false,
|
||||||
|
libraryName: 'E2E测试库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建一个测试角色(status=running 才会在角色库展示)
|
||||||
|
testRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '测试角色A',
|
||||||
|
gender: 'female',
|
||||||
|
age: '24',
|
||||||
|
relationship: '女友',
|
||||||
|
personality: '温柔可爱,善解人意',
|
||||||
|
background: '来自海边的小镇女孩',
|
||||||
|
speechStyle: '轻柔细腻',
|
||||||
|
greeting: '你好呀~',
|
||||||
|
desc: '温柔可爱的女友角色',
|
||||||
|
price: 29.9,
|
||||||
|
status: 'running',
|
||||||
|
avatar: 'https://example.com/avatar.png',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanDatabase();
|
||||||
|
await disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 以非创作者身份登录,登录后自动进入角色库
|
||||||
|
async function loginAsNonCreator(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('[data-action="open-characters"]').first().click();
|
||||||
|
await expect(page.locator('#auth')).toBeVisible();
|
||||||
|
// 确保登录 tab 激活
|
||||||
|
await page.locator('.auth-tabs [data-tab="login"]').click();
|
||||||
|
await page.locator('[data-form="login"]').waitFor();
|
||||||
|
await page.fill('[data-form="login"] [name="account"]', NON_CREATOR.account);
|
||||||
|
await page.fill('[data-form="login"] [name="password"]', NON_CREATOR.password);
|
||||||
|
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('角色库显示已上架角色', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 应显示测试角色
|
||||||
|
await expect(page.locator('#role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||||
|
await expect(page.locator('#role-list .role-card__name')).toHaveText('测试角色A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空角色库显示空状态', async ({ page }) => {
|
||||||
|
// 清空角色
|
||||||
|
await prisma.role.deleteMany();
|
||||||
|
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 应显示空状态
|
||||||
|
await expect(page.locator('#library-empty')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点击角色卡片进入详情页', async ({ page }) => {
|
||||||
|
// 重新创建角色
|
||||||
|
testRole = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: existingUser.id,
|
||||||
|
displayName: '详情测试角色',
|
||||||
|
gender: 'male',
|
||||||
|
personality: '阳光开朗',
|
||||||
|
background: '运动少年',
|
||||||
|
speechStyle: '热情直接',
|
||||||
|
greeting: '嘿!你好!',
|
||||||
|
desc: '阳光开朗的男孩',
|
||||||
|
price: 19.9,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
// 点击角色卡片
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 验证详情内容
|
||||||
|
await expect(page.locator('#detail-name')).toHaveText('详情测试角色');
|
||||||
|
await expect(page.locator('#detail-role-name')).toHaveText('详情测试角色');
|
||||||
|
await expect(page.locator('#detail-price')).toContainText('19.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色详情页返回按钮', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 点击返回(role-detail 使用 back-to-library)
|
||||||
|
await page.locator('[data-action="back-to-library"]').click();
|
||||||
|
await expect(page.locator('#role-library')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('角色详情付款流程', async ({ page }) => {
|
||||||
|
await loginAsNonCreator(page);
|
||||||
|
|
||||||
|
await page.locator('#role-list .role-card').first().click();
|
||||||
|
await expect(page.locator('#role-detail')).toBeVisible();
|
||||||
|
|
||||||
|
// 付款前:pre-pay 按钮区可见,paid 区隐藏
|
||||||
|
await expect(page.locator('#detail-actions-pre')).toBeVisible();
|
||||||
|
await expect(page.locator('#detail-paid')).toBeHidden();
|
||||||
|
|
||||||
|
// 点击付款按钮(data-action="pay",非 pay-role)
|
||||||
|
await page.locator('[data-action="pay"]').click();
|
||||||
|
|
||||||
|
// 应显示已付款区域(含二维码 / 头像下载)
|
||||||
|
await expect(page.locator('#detail-paid')).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(page.locator('#detail-actions-pre')).toBeHidden();
|
||||||
|
await expect(page.locator('[data-action="download-avatar"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'eternalai',
|
||||||
|
script: 'server.js',
|
||||||
|
cwd: __dirname,
|
||||||
|
instances: 1,
|
||||||
|
exec_mode: 'fork',
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
restart_delay: 3000,
|
||||||
|
max_memory_restart: '512M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
error_file: './logs/err.log',
|
||||||
|
out_file: './logs/out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
merge_logs: true,
|
||||||
|
time: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
|
|
@ -0,0 +1,613 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Eternal AI - 在记忆与陪伴中,遇见更懂你的 AI</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="styles.css?v=7" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#landing" class="skip-link">跳到主内容</a>
|
||||||
|
<main class="app">
|
||||||
|
<!-- P1: Landing View -->
|
||||||
|
<section id="landing" class="view view--landing active">
|
||||||
|
<div class="cards">
|
||||||
|
<article class="card card--characters" data-action="open-characters">
|
||||||
|
<div class="card__frame" aria-hidden="true"></div>
|
||||||
|
<div class="card__content">
|
||||||
|
<h2 class="card__title" id="library-name">我的 [XXX]</h2>
|
||||||
|
<p class="card__desc" id="characters-desc">登录后管理你的角色</p>
|
||||||
|
<button class="btn btn--outline" type="button" id="characters-btn">登录 / 注册</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card card--distill" data-action="open-distill">
|
||||||
|
<div class="card__frame" aria-hidden="true"></div>
|
||||||
|
<div class="card__content">
|
||||||
|
<h2 class="card__title">蒸馏前任</h2>
|
||||||
|
<p class="card__desc">留住你心里的 ta</p>
|
||||||
|
<button class="btn btn--primary" type="button">
|
||||||
|
开始蒸馏
|
||||||
|
<span class="btn__arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="landing-footer">
|
||||||
|
<a href="#" class="footer-link" data-action="open-about">关于 Eternal AI →</a>
|
||||||
|
<a href="#" class="footer-link" data-action="open-onboarding">我是创作者,申请入驻 →</a>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Auth View (Login / Register) -->
|
||||||
|
<section id="auth" class="view view--auth">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">Eternal AI</span>
|
||||||
|
<span class="creator-brand__step">登录 / 注册</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="auth-tabs" role="tablist" aria-label="登录与注册">
|
||||||
|
<button class="auth-tab active" type="button" role="tab" aria-selected="true" data-tab="login">登录</button>
|
||||||
|
<button class="auth-tab" type="button" role="tab" aria-selected="false" data-tab="register">注册</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" class="auth-form active" autocomplete="on" data-form="login">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">手机号 / 用户名</span>
|
||||||
|
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="请输入手机号或用户名" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">密码</span>
|
||||||
|
<input class="field__input" name="password" type="password" autocomplete="current-password" placeholder="请输入密码" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">登录</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-hint">登录后可查看角色库、管理已订阅的角色。</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="register-form" class="auth-form" autocomplete="on" data-form="register">
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">手机号 / 用户名</span>
|
||||||
|
<input class="field__input" name="account" type="text" autocomplete="username" placeholder="设置登录账号" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">密码</span>
|
||||||
|
<input class="field__input" name="password" type="password" autocomplete="new-password" placeholder="设置密码" required minlength="6" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">确认密码</span>
|
||||||
|
<input class="field__input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="再次输入密码" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">注册</button>
|
||||||
|
</div>
|
||||||
|
<p class="auth-hint">注册即代表同意《用户协议》和《隐私政策》。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P2: Role Library View -->
|
||||||
|
<section id="role-library" class="view view--role-library">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name" id="library-title">我的角色库</span>
|
||||||
|
<span class="creator-brand__step">选择一个角色开始对话</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="role-list" id="role-list" aria-live="polite" aria-label="角色列表"></div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="library-empty" hidden>
|
||||||
|
<p class="empty-state__text">你还没有绑定专属创作者</p>
|
||||||
|
<p class="empty-state__hint">通过创作者的专属链接进入即可绑定</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P3: Role Detail View -->
|
||||||
|
<section id="role-detail" class="view view--role-detail">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back-to-library" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name" id="detail-name">角色详情</span>
|
||||||
|
<span class="creator-brand__step">了解 ta 并订阅</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="detail-page" id="detail-page">
|
||||||
|
<div class="detail-hero" id="detail-hero"></div>
|
||||||
|
<h2 class="detail-title" id="detail-role-name"></h2>
|
||||||
|
<p class="detail-desc" id="detail-role-desc"></p>
|
||||||
|
<div class="detail-price" id="detail-price"></div>
|
||||||
|
|
||||||
|
<div class="form-actions" id="detail-actions-pre">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="pay">立即订阅</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-paid" id="detail-paid" hidden>
|
||||||
|
<div class="detail-qr" id="detail-qr"></div>
|
||||||
|
<p class="detail-paid__hint">扫码添加后,请将下方头像保存并设置为该联系人的备注头像,获得更完整的体验</p>
|
||||||
|
<div class="detail-avatar" id="detail-avatar"></div>
|
||||||
|
<button class="btn btn--outline btn--block" type="button" data-action="download-avatar">下载角色头像</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P4: Distill Ex View -->
|
||||||
|
<section id="distill" class="view view--distill-page">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">Eternal AI</span>
|
||||||
|
<span class="creator-brand__step">蒸馏前任</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="distill-page">
|
||||||
|
<a href="#" class="distill-quick" data-action="contact-wechat">没耐心?直接加微信定制沟通 →</a>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">什么是蒸馏前任?</h3>
|
||||||
|
<p class="distill-section__text">把你们曾经的聊天记录、语音习惯、相处细节交给我们,技术团队会将其蒸馏成一个可对话的 AI 前任。ta 会记得你们的暗号、说话节奏,甚至那些只有你们懂的小情绪。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">适合谁?</h3>
|
||||||
|
<ul class="distill-list">
|
||||||
|
<li>想好好告别,却还没说完话的人</li>
|
||||||
|
<li>希望把一段关系以安全方式封存的人</li>
|
||||||
|
<li>想借 AI 完成自我疗愈、练习表达的人</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-section">
|
||||||
|
<h3 class="distill-section__title">服务流程</h3>
|
||||||
|
<ol class="distill-steps">
|
||||||
|
<li><span>①</span> 下单付款</li>
|
||||||
|
<li><span>②</span> 客服指导导出聊天记录</li>
|
||||||
|
<li><span>③</span> 上传至指定云盘</li>
|
||||||
|
<li><span>④</span> 技术人员处理蒸馏</li>
|
||||||
|
<li><span>⑤</span> 完成后获得专属二维码和头像</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-price">
|
||||||
|
<span class="distill-price__label">标准版</span>
|
||||||
|
<span class="distill-price__value">¥ 199</span>
|
||||||
|
<span class="distill-price__unit">/ 次</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="pay-distill">立即下单</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-note">
|
||||||
|
<p>付款后请添加客服微信,我们将一对一指导你完成聊天记录导出与上传。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="distill-revenue">
|
||||||
|
<p>创作者可推广蒸馏前任服务获得分润,具体比例请在创作者管理中心配置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P5: About View -->
|
||||||
|
<section id="about" class="view view--about">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">关于 Eternal AI</span>
|
||||||
|
<span class="creator-brand__step">安全 · 隐私 · 信任</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="about-page">
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">平台简介</h3>
|
||||||
|
<p class="about-section__text">Eternal AI 是一个 AI 陪伴平台,帮助创作者将人设设定部署为可对话的 AI 角色,让用户在微信中与 ta 交流。我们致力于在记忆与陪伴中,遇见更懂你的 AI。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">连接方式说明</h3>
|
||||||
|
<p class="about-section__text">Eternal AI 基于微信 Claw 开放协议(iLink),非破解、非抓包,在微信官方授权范围内实现 AI 联系人接入。你的微信账号安全不受影响。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">安全类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">你们能看到我其他的微信聊天吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不能。我们只能看到你与 AI 角色的对话,无法访问你的其他聊天、朋友圈或通讯录。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">会不会偷偷读我的朋友圈和通讯录?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。我们仅通过 Claw 协议与 AI 联系人通信,不具备读取你朋友圈或通讯录的能力。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">隐私类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">我和 AI 说的话会被保存吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>对话内容会保存在你的专属记忆库中,用于 AI 角色记住你们的对话上下文。仅你和你的 AI 角色可访问,我们不会人工查看。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">蒸馏前任要上传聊天记录,这些数据安全吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>上传的聊天记录仅用于训练你的专属 AI 前任,处理完成后原始数据将被删除。我们承诺不会将你的数据用于其他用途。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">账号类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">用了会不会封我的微信号?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。Claw 协议在微信官方授权范围内运行,与正常添加联系人无异,不存在封号风险。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">扫码之后对方能控制我的微信吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不能。AI 角色只是一个普通的微信联系人,只能与你收发消息,无法控制你的账号。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-section">
|
||||||
|
<h3 class="about-section__title">情感类</h3>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">我聊的内容会有人看吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会有人工查看你的对话内容。除非你主动联系客服反馈问题,我们才会根据你的授权查看相关记录。</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="faq-item">
|
||||||
|
<button class="faq-q" type="button">你们会把我的对话内容拿去训练 AI 吗?<span class="faq-icon">+</span></button>
|
||||||
|
<div class="faq-a"><p>不会。你的对话内容仅用于你自己的 AI 角色的记忆,绝不会用于训练其他模型或分享给第三方。</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P6: Creator Onboarding View -->
|
||||||
|
<section id="onboarding" class="view view--onboarding">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">创作者入驻</span>
|
||||||
|
<span class="creator-brand__step">部署 AI 角色,私域变现</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="onboarding-page">
|
||||||
|
<div class="onboarding-section">
|
||||||
|
<h3 class="onboarding-section__title">平台能为创作者提供什么</h3>
|
||||||
|
<ul class="onboarding-list">
|
||||||
|
<li>部署 AI 角色:提交人设设定,平台负责技术部署</li>
|
||||||
|
<li>资金中转:平台处理用户付款,按比例结算给创作者</li>
|
||||||
|
<li>私域变现:通过专属链接绑定粉丝,持续获得订阅收入</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-section">
|
||||||
|
<h3 class="onboarding-section__title">合作模式</h3>
|
||||||
|
<p class="onboarding-section__text">创作者提交角色人设,平台部署上线。用户付款后,平台抽佣 20%,80% 转给创作者。结算周期为月结。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-section onboarding-contact">
|
||||||
|
<h3 class="onboarding-section__title">联系入驻</h3>
|
||||||
|
<div class="onboarding-qr" id="onboarding-qr"></div>
|
||||||
|
<p class="onboarding-wechat">微信扫码添加负责人</p>
|
||||||
|
<p class="onboarding-wechat-id">微信号:EternalAI_Official</p>
|
||||||
|
<p class="onboarding-note">添加时请备注「创作者入驻」,我们会一对一沟通合作细节。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- P7: Creator Center View -->
|
||||||
|
<section id="creator-center" class="view view--creator-center">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">创作者管理中心</span>
|
||||||
|
<span class="creator-brand__step" id="center-tab-label">我的角色</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="center-tabs" role="tablist" aria-label="创作者管理中心">
|
||||||
|
<button class="center-tab active" type="button" role="tab" aria-selected="true" data-center-tab="roles">我的角色</button>
|
||||||
|
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="income">收入</button>
|
||||||
|
<button class="center-tab" type="button" role="tab" aria-selected="false" data-center-tab="settings">我的</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Roles -->
|
||||||
|
<div class="center-panel active" id="center-roles">
|
||||||
|
<div class="role-list" id="creator-role-list" aria-live="polite" aria-label="我的角色列表"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="button" data-action="new-role">+ 新建角色</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Income -->
|
||||||
|
<div class="center-panel" id="center-income">
|
||||||
|
<div class="income-balance">
|
||||||
|
<span class="income-balance__label">可提现余额</span>
|
||||||
|
<span class="income-balance__value" id="income-balance">¥ 0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="income-section">
|
||||||
|
<h3 class="income-section__title">流水明细</h3>
|
||||||
|
<div class="income-list" id="income-list" aria-live="polite" aria-label="收入流水">
|
||||||
|
<p class="income-empty">暂无流水记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="income-section">
|
||||||
|
<h3 class="income-section__title">申请提现</h3>
|
||||||
|
<form id="withdraw-form" class="withdraw-form" autocomplete="off">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">收款方式</span>
|
||||||
|
<select class="field__input" name="method" required>
|
||||||
|
<option value="wechat">微信</option>
|
||||||
|
<option value="alipay">支付宝</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">提现金额(¥)</span>
|
||||||
|
<input class="field__input" name="amount" type="number" min="1" step="0.01" placeholder="请输入金额" required />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">提交提现申请</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Settings -->
|
||||||
|
<div class="center-panel" id="center-settings">
|
||||||
|
<form id="settings-form" class="settings-form" autocomplete="off">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">创作者名字</span>
|
||||||
|
<input class="field__input" name="creatorName" type="text" placeholder="你的笔名" id="settings-name" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">角色库名称(首页「我的 XXX」显示的文字)</span>
|
||||||
|
<input class="field__input" name="libraryName" type="text" placeholder="如:云朵的后宫" id="settings-library" />
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary btn--block" type="submit">保存设置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- API Key 管理 -->
|
||||||
|
<div class="api-key-section" style="margin-top: 2rem;">
|
||||||
|
<h3 class="settings-section-title">API Key 管理</h3>
|
||||||
|
<p class="settings-section-desc" style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||||
|
用于在 Hermes 机器上通过 curl 拉取角色配置文件
|
||||||
|
</p>
|
||||||
|
<div class="form-actions" style="margin-bottom: 1rem;">
|
||||||
|
<button class="btn btn--outline btn--block" type="button" data-action="generate-apikey">生成新 API Key</button>
|
||||||
|
</div>
|
||||||
|
<div id="apikey-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--ghost btn--block" type="button" data-action="logout">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Creator Role Edit View (formerly character creator) -->
|
||||||
|
<section id="creator" class="view view--creator">
|
||||||
|
<header class="creator-header">
|
||||||
|
<button class="icon-btn" type="button" data-action="back-to-center" aria-label="返回">←</button>
|
||||||
|
<div class="creator-brand">
|
||||||
|
<span class="creator-brand__name">角色编辑</span>
|
||||||
|
<span class="creator-brand__step">生成配置文件</span>
|
||||||
|
</div>
|
||||||
|
<div class="stepper" aria-hidden="true">
|
||||||
|
<span class="stepper__dot active"></span>
|
||||||
|
<span class="stepper__dot"></span>
|
||||||
|
<span class="stepper__dot"></span>
|
||||||
|
<span class="stepper__dot"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="character-form" class="creator-form" autocomplete="off">
|
||||||
|
<!-- Step 1: Identity -->
|
||||||
|
<fieldset class="form-step active" data-step="0">
|
||||||
|
<legend class="form-step__legend">基础身份</legend>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">角色代号 <small>(英文/数字,用于 config.yaml)</small></span>
|
||||||
|
<input class="field__input" name="agentId" type="text" placeholder="eternal_lover_001" required pattern="[a-zA-Z0-9_]+" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">显示名称</span>
|
||||||
|
<input class="field__input" name="displayName" type="text" placeholder=" ta 的名字" required />
|
||||||
|
</label>
|
||||||
|
<label class="field field--half">
|
||||||
|
<span class="field__label">性别</span>
|
||||||
|
<select class="field__input" name="gender">
|
||||||
|
<option value="unknown">未知</option>
|
||||||
|
<option value="female">女</option>
|
||||||
|
<option value="male">男</option>
|
||||||
|
<option value="nonbinary">非二元</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field field--half">
|
||||||
|
<span class="field__label">年龄</span>
|
||||||
|
<input class="field__input" name="age" type="text" placeholder="24" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--primary" type="button" data-action="next">下一步</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Step 2: Soul -->
|
||||||
|
<fieldset class="form-step" data-step="1">
|
||||||
|
<legend class="form-step__legend">灵魂设定(Soul.md)</legend>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">背景故事</span>
|
||||||
|
<textarea class="field__input field__input--tall" name="background" placeholder="你们是如何相遇的?ta 经历过什么?" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">性格标签 <small>(用逗号分隔)</small></span>
|
||||||
|
<input class="field__input" name="personality" type="text" placeholder="温柔, 敏感, 嘴硬心软, 浪漫主义" required />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">说话风格</span>
|
||||||
|
<textarea class="field__input" name="speechStyle" placeholder="常用语气词、句式、称呼方式、口头禅……" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">喜好</span>
|
||||||
|
<textarea class="field__input" name="likes" placeholder="喜欢的事物、活动、话题"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">厌恶 / 底线</span>
|
||||||
|
<textarea class="field__input" name="dislikes" placeholder="讨厌什么,哪些话题会触发情绪波动"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions form-actions--split">
|
||||||
|
<button class="btn btn--ghost" type="button" data-action="prev">上一步</button>
|
||||||
|
<button class="btn btn--primary" type="button" data-action="next">下一步</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Step 3: Relationships & Memory -->
|
||||||
|
<fieldset class="form-step" data-step="2">
|
||||||
|
<legend class="form-step__legend">关系与记忆</legend>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">与使用者的关系</span>
|
||||||
|
<input class="field__input" name="relationship" type="text" placeholder="前任、挚友、恋人、家人……" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">共同记忆 / 关键事件</span>
|
||||||
|
<textarea class="field__input field__input--tall" name="memories" placeholder="那些只属于你们的高光或遗憾时刻"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">秘密或未说出口的话</span>
|
||||||
|
<textarea class="field__input" name="secrets" placeholder="藏在角色心底、会间接影响语气的细节"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">开场白</span>
|
||||||
|
<textarea class="field__input" name="greeting" placeholder="ta 第一次开口会对你说什么?" required></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions form-actions--split">
|
||||||
|
<button class="btn btn--ghost" type="button" data-action="prev">上一步</button>
|
||||||
|
<button class="btn btn--primary" type="button" data-action="next">下一步</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Step 4: Config -->
|
||||||
|
<fieldset class="form-step" data-step="3">
|
||||||
|
<legend class="form-step__legend">运行配置(config.yaml)</legend>
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">模型</span>
|
||||||
|
<input class="field__input" name="model" type="text" value="hermes-3-llama-3.1-70b" />
|
||||||
|
</label>
|
||||||
|
<label class="field field--half">
|
||||||
|
<span class="field__label">Temperature</span>
|
||||||
|
<input class="field__input" name="temperature" type="number" step="0.05" min="0" max="2" value="0.85" />
|
||||||
|
</label>
|
||||||
|
<label class="field field--half">
|
||||||
|
<span class="field__label">Max Tokens</span>
|
||||||
|
<input class="field__input" name="maxTokens" type="number" min="1" value="1024" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field__label">系统提示词 <small>(可自动生成,也可手动覆盖)</small></span>
|
||||||
|
<textarea class="field__input field__input--tall" name="systemPrompt" id="system-prompt" placeholder="留空将根据 Soul.md 自动生成"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="field field--checkbox">
|
||||||
|
<input type="checkbox" name="enableMemory" checked />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="field__label">启用长期记忆</span>
|
||||||
|
</label>
|
||||||
|
<label class="field field--checkbox">
|
||||||
|
<input type="checkbox" name="enableTools" />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
<span class="field__label">启用外部工具</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions form-actions--split">
|
||||||
|
<button class="btn btn--ghost" type="button" data-action="prev">上一步</button>
|
||||||
|
<button class="btn btn--primary" type="button" data-action="publish">生成并发布</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Result Panel -->
|
||||||
|
<div id="result-panel" class="result-panel" hidden>
|
||||||
|
<div class="result-panel__header">
|
||||||
|
<h3 class="result-panel__title">角色已蒸馏完成</h3>
|
||||||
|
<p class="result-panel__desc">下载 Hermes agent 配置文件,或复制到项目中使用。</p>
|
||||||
|
</div>
|
||||||
|
<div class="result-panel__files">
|
||||||
|
<div class="file-card">
|
||||||
|
<div class="file-card__icon">📜</div>
|
||||||
|
<div class="file-card__info">
|
||||||
|
<strong>Soul.md</strong>
|
||||||
|
<small>角色灵魂文档</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--small btn--outline" type="button" data-download="soul">下载</button>
|
||||||
|
</div>
|
||||||
|
<div class="file-card">
|
||||||
|
<div class="file-card__icon">⚙️</div>
|
||||||
|
<div class="file-card__info">
|
||||||
|
<strong>config.yaml</strong>
|
||||||
|
<small>运行配置</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--small btn--outline" type="button" data-download="config">下载</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-panel__preview">
|
||||||
|
<div class="preview-tabs" role="tablist" aria-label="配置文件预览">
|
||||||
|
<button class="preview-tab active" type="button" role="tab" aria-selected="true" data-tab="soul">Soul.md</button>
|
||||||
|
<button class="preview-tab" type="button" role="tab" aria-selected="false" data-tab="config">config.yaml</button>
|
||||||
|
</div>
|
||||||
|
<pre class="preview-code" id="preview-code"><code></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn--ghost" type="button" data-action="reset">再创建一个</button>
|
||||||
|
<button class="btn btn--primary" type="button" data-action="back-to-center">返回管理中心</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Tab Bar (U8) -->
|
||||||
|
<nav class="tab-bar" id="tab-bar" role="tablist" aria-label="主导航">
|
||||||
|
<button class="tab-bar__item active" type="button" role="tab" aria-selected="true" data-tab-action="tab-home">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">⌂</span>
|
||||||
|
<span class="tab-bar__label">首页</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-distill">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">♡</span>
|
||||||
|
<span class="tab-bar__label">蒸馏前任</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab-bar__item" type="button" role="tab" aria-selected="false" data-tab-action="tab-mine">
|
||||||
|
<span class="tab-bar__icon" aria-hidden="true">☺</span>
|
||||||
|
<span class="tab-bar__label" id="tab-mine-label">我的</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Screen reader live region for view change announcements -->
|
||||||
|
<div id="sr-announce" class="visually-hidden" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "eternalai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI 陪伴平台 — 人设创作者设定发布,生成 Hermes agent 配置文件",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"deploy": "bash deploy/deploy.sh"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"eternal",
|
||||||
|
"agent",
|
||||||
|
"hermes"
|
||||||
|
],
|
||||||
|
"author": "chigulong",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "http://gitea.fischerai.cn/chigulong/eternalai.git"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"prisma": "^5.22.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.54"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 1,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
timeout: 60000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:3001',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
actionTimeout: 15000,
|
||||||
|
navigationTimeout: 30000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
channel: 'chrome',
|
||||||
|
browserName: 'chromium',
|
||||||
|
viewport: { width: 1280, height: 1200 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'node server.js',
|
||||||
|
url: 'http://localhost:3001',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
account String @unique
|
||||||
|
password String
|
||||||
|
isCreator Boolean @default(false)
|
||||||
|
creatorName String?
|
||||||
|
libraryName String?
|
||||||
|
boundCreator String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
roles Role[]
|
||||||
|
orders Order[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
name String @default("default")
|
||||||
|
keyHash String @unique
|
||||||
|
keyPrefix String // 前 12 位,用于脱敏显示
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
creatorId String
|
||||||
|
displayName String
|
||||||
|
gender String @default("unknown")
|
||||||
|
age String?
|
||||||
|
relationship String?
|
||||||
|
personality String
|
||||||
|
background String
|
||||||
|
speechStyle String
|
||||||
|
likes String?
|
||||||
|
dislikes String?
|
||||||
|
memories String?
|
||||||
|
secrets String?
|
||||||
|
greeting String
|
||||||
|
systemPrompt String?
|
||||||
|
model String @default("gpt-4o")
|
||||||
|
temperature Float @default(0.8)
|
||||||
|
maxTokens Int @default(2048)
|
||||||
|
enableMemory Boolean @default(true)
|
||||||
|
enableTools Boolean @default(false)
|
||||||
|
agentId String?
|
||||||
|
soulMd String?
|
||||||
|
configYaml String?
|
||||||
|
avatar String?
|
||||||
|
desc String?
|
||||||
|
price Float @default(0)
|
||||||
|
status String @default("running")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
creator User @relation(fields: [creatorId], references: [id])
|
||||||
|
orders Order[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
roleId String
|
||||||
|
amount Float
|
||||||
|
status String @default("paid")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
require('dotenv').config();
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// API 路由
|
||||||
|
app.use('/api/auth', require('./src/routes/auth'));
|
||||||
|
app.use('/api/roles', require('./src/routes/roles'));
|
||||||
|
app.use('/api/apikeys', require('./src/routes/apikeys'));
|
||||||
|
app.use('/api/hermes', require('./src/routes/hermes'));
|
||||||
|
|
||||||
|
// 静态文件
|
||||||
|
app.use(express.static('.'));
|
||||||
|
|
||||||
|
// 主页
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`EternalAI server running on http://0.0.0.0:${PORT}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const prisma = require('./prisma');
|
||||||
|
|
||||||
|
const JWT_EXPIRES_IN = '7d';
|
||||||
|
|
||||||
|
// 安全:生产环境必须配置 JWT_SECRET,杜绝硬编码密钥
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
if (!JWT_SECRET) {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error(
|
||||||
|
'JWT_SECRET 环境变量未设置。请在 .env 文件中配置一个随机密钥(可用 `openssl rand -hex 32` 生成)。'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.warn('[安全警告] JWT_SECRET 未设置,使用开发环境临时密钥。请勿在生产环境使用。');
|
||||||
|
}
|
||||||
|
const SECRET = JWT_SECRET || 'dev_only_insecure_secret_do_not_use_in_production';
|
||||||
|
|
||||||
|
// 哈希密码
|
||||||
|
function hashPassword(password) {
|
||||||
|
return bcrypt.hashSync(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
function verifyPassword(password, hash) {
|
||||||
|
return bcrypt.compareSync(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JWT
|
||||||
|
function signToken(userId) {
|
||||||
|
return jwt.sign({ userId }, SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 JWT 并提取 userId
|
||||||
|
function verifyToken(token) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, SECRET);
|
||||||
|
return decoded.userId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express 中间件:验证 JWT
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: '未登录' });
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const userId = verifyToken(token);
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: '登录已过期,请重新登录' });
|
||||||
|
}
|
||||||
|
req.userId = userId;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Express 中间件:验证 API Key(用于 Hermes 配置拉取)
|
||||||
|
// 支持 JWT token 和 API Key(eak_ 前缀)两种认证方式
|
||||||
|
async function apiKeyMiddleware(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: '需要认证' });
|
||||||
|
}
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
|
||||||
|
// 如果是 API Key(eak_ 前缀),查数据库验证
|
||||||
|
if (token.startsWith('eak_')) {
|
||||||
|
try {
|
||||||
|
const keyHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const apiKey = await prisma.apiKey.findUnique({
|
||||||
|
where: { keyHash },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
});
|
||||||
|
if (!apiKey) {
|
||||||
|
return res.status(401).json({ error: 'API Key 无效' });
|
||||||
|
}
|
||||||
|
// 更新最后使用时间(不阻塞请求)
|
||||||
|
prisma.apiKey.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
}).catch((err) => console.warn('更新 API Key lastUsedAt 失败:', err));
|
||||||
|
req.userId = apiKey.userId;
|
||||||
|
req.authMethod = 'apikey';
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API Key 验证失败:', err);
|
||||||
|
return res.status(500).json({ error: '认证失败' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则尝试 JWT 认证
|
||||||
|
const userId = verifyToken(token);
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(401).json({ error: '认证无效' });
|
||||||
|
}
|
||||||
|
req.userId = userId;
|
||||||
|
req.authMethod = 'jwt';
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
signToken,
|
||||||
|
verifyToken,
|
||||||
|
authMiddleware,
|
||||||
|
apiKeyMiddleware,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
module.exports = prisma;
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 生成 API Key
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
// 生成随机 API Key:eak_ + 32 位 hex
|
||||||
|
const rawKey = `eak_${crypto.randomBytes(16).toString('hex')}`;
|
||||||
|
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
const keyPrefix = rawKey.slice(0, 12); // eak_xxxxxxxx
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId,
|
||||||
|
name: name || 'default',
|
||||||
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 明文 Key 只返回一次
|
||||||
|
res.json({ apiKey: { ...apiKey, key: rawKey } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('生成 API Key 失败:', err);
|
||||||
|
res.status(500).json({ error: '生成失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 列出当前用户的所有 API Key
|
||||||
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
res.json({ apiKeys: keys });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 API Key 列表失败:', err);
|
||||||
|
res.status(500).json({ error: '获取失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 API Key
|
||||||
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.apiKey.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
});
|
||||||
|
if (!existing || existing.userId !== req.userId) {
|
||||||
|
return res.status(404).json({ error: 'API Key 不存在' });
|
||||||
|
}
|
||||||
|
await prisma.apiKey.delete({ where: { id: req.params.id } });
|
||||||
|
res.json({ deleted: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除 API Key 失败:', err);
|
||||||
|
res.status(500).json({ error: '删除失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { hashPassword, verifyPassword, signToken, authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { account, password } = req.body;
|
||||||
|
if (!account || !password) {
|
||||||
|
return res.status(400).json({ error: '账号和密码不能为空' });
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: '密码至少 6 位' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { account } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ error: '该账号已注册' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account,
|
||||||
|
password: hashPassword(password),
|
||||||
|
libraryName: '我的角色库',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = signToken(user.id);
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
account: user.account,
|
||||||
|
isCreator: user.isCreator,
|
||||||
|
creatorName: user.creatorName,
|
||||||
|
libraryName: user.libraryName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('注册失败:', err);
|
||||||
|
res.status(500).json({ error: '注册失败,请稍后重试' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { account, password } = req.body;
|
||||||
|
if (!account || !password) {
|
||||||
|
return res.status(400).json({ error: '账号和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { account } });
|
||||||
|
if (!user || !verifyPassword(password, user.password)) {
|
||||||
|
return res.status(401).json({ error: '账号或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken(user.id);
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
account: user.account,
|
||||||
|
isCreator: user.isCreator,
|
||||||
|
creatorName: user.creatorName,
|
||||||
|
libraryName: user.libraryName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登录失败:', err);
|
||||||
|
res.status(500).json({ error: '登录失败,请稍后重试' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
router.get('/me', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
account: true,
|
||||||
|
isCreator: true,
|
||||||
|
creatorName: true,
|
||||||
|
libraryName: true,
|
||||||
|
boundCreator: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: '用户不存在' });
|
||||||
|
}
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取用户信息失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新用户设置
|
||||||
|
router.put('/settings', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { creatorName, libraryName, isCreator } = req.body;
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.userId },
|
||||||
|
data: {
|
||||||
|
...(creatorName !== undefined && { creatorName }),
|
||||||
|
...(libraryName !== undefined && { libraryName }),
|
||||||
|
...(isCreator !== undefined && { isCreator }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
account: true,
|
||||||
|
isCreator: true,
|
||||||
|
creatorName: true,
|
||||||
|
libraryName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('更新设置失败:', err);
|
||||||
|
res.status(500).json({ error: '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { apiKeyMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 转义 YAML 字符串值:双引号包裹,转义内部反斜杠和双引号
|
||||||
|
function yamlString(value) {
|
||||||
|
const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤注释行中的换行符,防止注释注入
|
||||||
|
function sanitizeComment(value) {
|
||||||
|
return String(value).replace(/[\r\n]/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 EternalAI 角色数据适配为 Hermes config.yaml 格式
|
||||||
|
function adaptToHermesConfig(role) {
|
||||||
|
const lines = [
|
||||||
|
'# Hermes Agent Config — generated by EternalAI',
|
||||||
|
`# Character: ${sanitizeComment(role.displayName)}`,
|
||||||
|
`# Agent ID: ${sanitizeComment(role.agentId || 'default')}`,
|
||||||
|
`# Generated at: ${new Date().toISOString()}`,
|
||||||
|
'',
|
||||||
|
`model: ${yamlString(role.model)}`,
|
||||||
|
`temperature: ${role.temperature ?? 0.85}`,
|
||||||
|
`max_tokens: ${role.maxTokens ?? 2048}`,
|
||||||
|
'',
|
||||||
|
'memory:',
|
||||||
|
` enabled: ${role.enableMemory ?? true}`,
|
||||||
|
' storage: local',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (role.enableTools) {
|
||||||
|
lines.push('', 'tools:', ' enabled: true', ' toolsets:', ' - memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('', 'terminal:', ' backend: local', '');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/hermes/roles/:id/SOUL.md — 返回 SOUL.md 内容
|
||||||
|
router.get('/roles/:id/SOUL.md', apiKeyMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: { id: true, creatorId: true, displayName: true, soulMd: true },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: 'Role not found' });
|
||||||
|
}
|
||||||
|
if (role.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: not the role owner' });
|
||||||
|
}
|
||||||
|
if (!role.soulMd) {
|
||||||
|
return res.status(404).json({ error: 'SOUL.md not generated for this role' });
|
||||||
|
}
|
||||||
|
res.type('text/plain').send(role.soulMd);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 SOUL.md 失败:', err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/hermes/roles/:id/config.yaml — 返回适配后的 Hermes config.yaml
|
||||||
|
router.get('/roles/:id/config.yaml', apiKeyMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
creatorId: true,
|
||||||
|
displayName: true,
|
||||||
|
agentId: true,
|
||||||
|
model: true,
|
||||||
|
temperature: true,
|
||||||
|
maxTokens: true,
|
||||||
|
enableMemory: true,
|
||||||
|
enableTools: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: 'Role not found' });
|
||||||
|
}
|
||||||
|
if (role.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden: not the role owner' });
|
||||||
|
}
|
||||||
|
const config = adaptToHermesConfig(role);
|
||||||
|
res.type('text/plain').send(config);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取 config.yaml 失败:', err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
const express = require('express');
|
||||||
|
const prisma = require('../lib/prisma');
|
||||||
|
const { authMiddleware } = require('../lib/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 获取角色库(所有已上架角色)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const roles = await prisma.role.findMany({
|
||||||
|
where: { status: 'running' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
desc: true,
|
||||||
|
price: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ roles });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取角色列表失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取角色详情
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
desc: true,
|
||||||
|
price: true,
|
||||||
|
status: true,
|
||||||
|
gender: true,
|
||||||
|
age: true,
|
||||||
|
relationship: true,
|
||||||
|
personality: true,
|
||||||
|
background: true,
|
||||||
|
speechStyle: true,
|
||||||
|
greeting: true,
|
||||||
|
creatorId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
res.json({ role });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取角色详情失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取当前用户创建的角色
|
||||||
|
router.get('/my/roles', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const roles = await prisma.role.findMany({
|
||||||
|
where: { creatorId: req.userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
res.json({ roles });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取我的角色失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发布新角色
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
if (!data.displayName || !data.greeting || !data.personality || !data.background || !data.speechStyle) {
|
||||||
|
return res.status(400).json({ error: '必填字段缺失' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
creatorId: req.userId,
|
||||||
|
displayName: data.displayName,
|
||||||
|
gender: data.gender || 'unknown',
|
||||||
|
age: data.age || null,
|
||||||
|
relationship: data.relationship || null,
|
||||||
|
personality: data.personality,
|
||||||
|
background: data.background,
|
||||||
|
speechStyle: data.speechStyle,
|
||||||
|
likes: data.likes || null,
|
||||||
|
dislikes: data.dislikes || null,
|
||||||
|
memories: data.memories || null,
|
||||||
|
secrets: data.secrets || null,
|
||||||
|
greeting: data.greeting,
|
||||||
|
systemPrompt: data.systemPrompt || null,
|
||||||
|
model: data.model || 'gpt-4o',
|
||||||
|
temperature: parseFloat(data.temperature) || 0.8,
|
||||||
|
maxTokens: parseInt(data.maxTokens) || 2048,
|
||||||
|
enableMemory: data.enableMemory ?? true,
|
||||||
|
enableTools: data.enableTools ?? false,
|
||||||
|
agentId: data.agentId || null,
|
||||||
|
soulMd: data.soulMd || null,
|
||||||
|
configYaml: data.configYaml || null,
|
||||||
|
avatar: data.avatar || null,
|
||||||
|
desc: data.desc || data.personality.slice(0, 50),
|
||||||
|
price: parseFloat(data.price) || 0,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ role });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('发布角色失败:', err);
|
||||||
|
res.status(500).json({ error: '发布失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 编辑角色
|
||||||
|
router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.role.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (existing.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: '无权编辑他人角色' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = req.body;
|
||||||
|
const role = await prisma.role.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: {
|
||||||
|
displayName: data.displayName ?? existing.displayName,
|
||||||
|
gender: data.gender ?? existing.gender,
|
||||||
|
age: data.age ?? existing.age,
|
||||||
|
relationship: data.relationship ?? existing.relationship,
|
||||||
|
personality: data.personality ?? existing.personality,
|
||||||
|
background: data.background ?? existing.background,
|
||||||
|
speechStyle: data.speechStyle ?? existing.speechStyle,
|
||||||
|
likes: data.likes ?? existing.likes,
|
||||||
|
dislikes: data.dislikes ?? existing.dislikes,
|
||||||
|
memories: data.memories ?? existing.memories,
|
||||||
|
secrets: data.secrets ?? existing.secrets,
|
||||||
|
greeting: data.greeting ?? existing.greeting,
|
||||||
|
systemPrompt: data.systemPrompt ?? existing.systemPrompt,
|
||||||
|
model: data.model ?? existing.model,
|
||||||
|
temperature: parseFloat(data.temperature) || existing.temperature,
|
||||||
|
maxTokens: parseInt(data.maxTokens) || existing.maxTokens,
|
||||||
|
enableMemory: data.enableMemory ?? existing.enableMemory,
|
||||||
|
enableTools: data.enableTools ?? existing.enableTools,
|
||||||
|
soulMd: data.soulMd ?? existing.soulMd,
|
||||||
|
configYaml: data.configYaml ?? existing.configYaml,
|
||||||
|
avatar: data.avatar ?? existing.avatar,
|
||||||
|
desc: data.desc ?? existing.desc,
|
||||||
|
price: parseFloat(data.price) || existing.price,
|
||||||
|
status: data.status ?? existing.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ role });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('编辑角色失败:', err);
|
||||||
|
res.status(500).json({ error: '编辑失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取角色详情(含 Soul.md 和 config.yaml,仅创建者可访问)
|
||||||
|
router.get('/:id/full', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.role.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!role) {
|
||||||
|
return res.status(404).json({ error: '角色不存在' });
|
||||||
|
}
|
||||||
|
if (role.creatorId !== req.userId) {
|
||||||
|
return res.status(403).json({ error: '无权查看' });
|
||||||
|
}
|
||||||
|
res.json({ role });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取角色完整信息失败:', err);
|
||||||
|
res.status(500).json({ error: '服务器错误' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue