Compare commits

..

1 Commits

Author SHA1 Message Date
chigulong 16ec3d1fd8 Add full navigation feature 2026-06-20 18:28:23 +08:00
38 changed files with 3 additions and 8838 deletions

View File

@ -1,18 +0,0 @@
# ===== 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=""

View File

@ -1,72 +0,0 @@
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 "============================================"

7
.gitignore vendored
View File

@ -1,7 +0,0 @@
node_modules/
.DS_Store
*.log
.env
test-results/
playwright-report/
logs/

Binary file not shown.

305
README.md
View File

@ -1,306 +1,3 @@
# EternalAI
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 SPAIIFE 模式,无框架)、玻璃拟态 UI |
| 后端 | Node.js + Express 5.x |
| 数据库 | PostgreSQL 15 + Prisma ORM 5.x |
| 认证 | JWT7 天有效期)+ API Key`eak_` 前缀SHA-256 哈希存储) |
| 密码 | bcryptjs10 轮盐) |
| 测试 | Playwright E2E35 个测试用例) |
| 部署 | 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 位随机 hex128 位熵)
- **存储**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.mdtext/plain |
| GET | `/api/hermes/roles/:id/config.yaml` | API Key / JWT | 拉取 config.yamltext/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
EternalAI Project Repository// Update to main branch

1294
app.js

File diff suppressed because it is too large Load Diff

View File

@ -1,99 +0,0 @@
#!/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 ""

View File

@ -1,87 +0,0 @@
# ===== 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;
}

View File

@ -1,198 +0,0 @@
#!/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 ""

View File

@ -1,100 +0,0 @@
# 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.yamltext/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

View File

@ -1,332 +0,0 @@
# 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
# 推送到 masterCI/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
```

View File

@ -1,375 +0,0 @@
# 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:** U1U8。
**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的示例角色数据是否需要用户提供还是先用 23 个虚构角色填充?
3. 首页「我的 XXX」中的 XXX 默认文案是否保留「我的 [XXX]」还是给一个默认占位如「我的角色库」?
---
## 10. Sources & Research
- `Eternal_AI_PRD_v1.docx` — 产品需求文档,定义 7 个页面及交互逻辑。
- 当前代码:`index.html`、`app.js`、`styles.css` — 现有单页应用结构与视图切换方式。

View File

@ -1,160 +0,0 @@
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(/登录/);
});
});

View File

@ -1,240 +0,0 @@
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 0agentId / 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('导航测试');
});
});

View File

@ -1,30 +0,0 @@
// 数据库清理 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 };

View File

@ -1,33 +0,0 @@
// 测试数据 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;

View File

@ -1,214 +0,0 @@
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);
});
});

View File

@ -1,148 +0,0 @@
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();
});
});

View File

@ -1,23 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -1,613 +0,0 @@
<!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>

1204
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +0,0 @@
{
"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"
}
}

View File

@ -1,33 +0,0 @@
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,
},
});

View File

@ -1,85 +0,0 @@
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])
}

View File

@ -1,29 +0,0 @@
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}`);
});

View File

@ -1,111 +0,0 @@
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 Keyeak_ 前缀)两种认证方式
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 Keyeak_ 前缀),查数据库验证
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,
};

View File

@ -1,5 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;

1
src/navigation/index.js Normal file
View File

@ -0,0 +1 @@
// Full navigation feature

View File

@ -1,78 +0,0 @@
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 Keyeak_ + 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;

View File

@ -1,128 +0,0 @@
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;

View File

@ -1,97 +0,0 @@
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;

View File

@ -1,187 +0,0 @@
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;

1416
styles.css

File diff suppressed because it is too large Load Diff