feat: add PostgreSQL + JWT backend, fix 4 critical issues (auth/role persistence/edit/library)

This commit is contained in:
chiguyong 2026-06-20 20:39:09 +08:00
parent 9f4ee690db
commit 6234c27138
9 changed files with 998 additions and 70 deletions

251
app.js
View File

@ -1,6 +1,38 @@
(() => { (() => {
'use strict'; 'use strict';
// --- API helper ---
const API_BASE = '/api';
const TOKEN_KEY = 'eternal_ai_token';
function getToken() {
return localStorage.getItem(TOKEN_KEY) || '';
}
function setToken(token) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
async function api(path, options = {}) {
const token = getToken();
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || `请求失败 (${res.status})`);
}
return data;
} catch (err) {
if (err.message === 'Failed to fetch') {
throw new Error('无法连接服务器,请检查网络');
}
throw err;
}
}
// --- U9: Unified state management --- // --- U9: Unified state management ---
const STORAGE_KEY = 'eternal_ai_state'; const STORAGE_KEY = 'eternal_ai_state';
@ -8,6 +40,7 @@
isLoggedIn: false, isLoggedIn: false,
isCreator: false, isCreator: false,
account: null, account: null,
userId: null,
boundCreator: null, boundCreator: null,
libraryName: '我的 [XXX]', libraryName: '我的 [XXX]',
creatorName: '', creatorName: '',
@ -93,6 +126,7 @@
let activeAuthTab = 'login'; let activeAuthTab = 'login';
let activeCenterTab = 'roles'; let activeCenterTab = 'roles';
let currentRole = null; let currentRole = null;
let editingRoleId = null;
let viewHistory = ['landing']; let viewHistory = ['landing'];
// --- U9: Unified showView with history and tab-bar sync --- // --- U9: Unified showView with history and tab-bar sync ---
@ -231,10 +265,13 @@
return true; return true;
} }
function login(account) { function applyUserData(user) {
state.isLoggedIn = true; state.isLoggedIn = true;
state.account = account; state.userId = user.id;
state.boundCreator = state.boundCreator || { name: '云朵', roles: mockRoles }; state.account = user.account;
state.isCreator = user.isCreator || false;
state.creatorName = user.creatorName || '';
state.libraryName = user.libraryName || '我的角色库';
saveState(); saveState();
updateLandingCard(); updateLandingCard();
} }
@ -243,55 +280,69 @@
state.isLoggedIn = false; state.isLoggedIn = false;
state.isCreator = false; state.isCreator = false;
state.account = null; state.account = null;
state.userId = null;
state.boundCreator = null;
state.roles = [];
state.income = { balance: 0, records: [] };
setToken('');
saveState(); saveState();
updateLandingCard(); updateLandingCard();
showView('landing'); showView('landing');
} }
// --- U2: Role Library --- // --- U2: Role Library ---
function renderRoleLibrary() { async function renderRoleLibrary() {
const listEl = document.getElementById('role-list'); const listEl = document.getElementById('role-list');
const emptyEl = document.getElementById('library-empty'); const emptyEl = document.getElementById('library-empty');
const titleEl = document.getElementById('library-title'); const titleEl = document.getElementById('library-title');
titleEl.textContent = state.libraryName || '我的角色库'; titleEl.textContent = state.libraryName || '我的角色库';
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
emptyEl.hidden = true;
if (!state.boundCreator) { try {
const { roles } = await api('/roles');
if (roles.length === 0) {
listEl.innerHTML = ''; listEl.innerHTML = '';
emptyEl.hidden = false; emptyEl.hidden = false;
return; return;
} }
listEl.innerHTML = roles
emptyEl.hidden = true;
listEl.innerHTML = mockRoles
.map( .map(
(role) => ` (role) => `
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.name}${role.desc},每月${role.price}元"> <article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.desc || ''},每月${role.price}元">
<img class="role-card__avatar" src="${role.avatar}" alt="${role.name}" /> <img class="role-card__avatar" src="${role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'}" alt="${role.displayName}" />
<div class="role-card__info"> <div class="role-card__info">
<h3 class="role-card__name">${role.name}</h3> <h3 class="role-card__name">${role.displayName}</h3>
<p class="role-card__desc">${role.desc}</p> <p class="role-card__desc">${role.desc || ''}</p>
<span class="role-card__price">¥${role.price}</span> <span class="role-card__price">¥${role.price}</span>
</div> </div>
</article>` </article>`
) )
.join(''); .join('');
} catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${err.message}</p>`;
}
} }
// --- U3: Role Detail --- // --- U3: Role Detail ---
function renderRoleDetail(roleId) { async function renderRoleDetail(roleId) {
const role = mockRoles.find((r) => r.id === roleId); try {
if (!role) return; const { role } = await api(`/roles/${roleId}`);
currentRole = role; currentRole = role;
document.getElementById('detail-name').textContent = role.name; document.getElementById('detail-name').textContent = role.displayName;
document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar})`; document.getElementById('detail-hero').style.backgroundImage = `url(${role.avatar || ''})`;
document.getElementById('detail-role-name').textContent = role.name; document.getElementById('detail-role-name').textContent = role.displayName;
document.getElementById('detail-role-desc').textContent = role.desc; document.getElementById('detail-role-desc').textContent = role.desc || role.personality || '';
document.getElementById('detail-price').innerHTML = `<span class="detail-price__value">¥${role.price}</span><span class="detail-price__unit">/ 月</span>`; document.getElementById('detail-price').innerHTML = `<span class="detail-price__value">¥${role.price}</span><span class="detail-price__unit">/ 月</span>`;
document.getElementById('detail-actions-pre').hidden = false; document.getElementById('detail-actions-pre').hidden = false;
document.getElementById('detail-paid').hidden = true; document.getElementById('detail-paid').hidden = true;
} catch (err) {
alert('加载角色详情失败:' + err.message);
goBack();
}
} }
function payRole() { function payRole() {
@ -348,22 +399,33 @@
renderSettings(); renderSettings();
} }
function renderCreatorRoles() { async function renderCreatorRoles() {
const listEl = document.getElementById('creator-role-list'); const listEl = document.getElementById('creator-role-list');
const roles = state.roles.length > 0 ? state.roles : mockRoles; listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">加载中…</p>';
try {
const { roles } = await api('/roles/my/roles');
state.roles = roles;
saveState();
if (roles.length === 0) {
listEl.innerHTML = '<p style="text-align:center;color:var(--text-muted)">还没有创建角色,点击「新建角色」开始</p>';
return;
}
listEl.innerHTML = roles listEl.innerHTML = roles
.map( .map(
(role) => ` (role) => `
<article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.name}${role.status === 'running' ? '运行中' : '已停止'}"> <article class="role-card" data-role-id="${role.id}" role="button" tabindex="0" aria-label="${role.displayName}${role.status === 'running' ? '运行中' : '已停止'}">
<img class="role-card__avatar" src="${role.avatar}" alt="${role.name}" /> <img class="role-card__avatar" src="${role.avatar || 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20character%20portrait&image_size=square'}" alt="${role.displayName}" />
<div class="role-card__info"> <div class="role-card__info">
<h3 class="role-card__name">${role.name}</h3> <h3 class="role-card__name">${role.displayName}</h3>
<span class="role-card__status role-card__status--${role.status}">${role.status === 'running' ? '运行中' : '已停止'}</span> <span class="role-card__status role-card__status--${role.status}">${role.status === 'running' ? '运行中' : '已停止'}</span>
</div> </div>
<button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${role.id}">编辑</button> <button class="btn btn--small btn--outline" type="button" data-action="edit-role" data-role-id="${role.id}">编辑</button>
</article>` </article>`
) )
.join(''); .join('');
} catch (err) {
listEl.innerHTML = `<p style="text-align:center;color:var(--text-muted)">加载失败:${err.message}</p>`;
}
} }
function renderIncome() { function renderIncome() {
@ -588,7 +650,7 @@ ${data.secrets || '无'}
} }
} }
function publish() { async function publish() {
if (!validateStep(currentStep)) return; if (!validateStep(currentStep)) return;
const data = getFormData(); const data = getFormData();
@ -599,9 +661,37 @@ ${data.secrets || '无'}
generatedSoul = generateSoulMd(data); generatedSoul = generateSoulMd(data);
generatedConfig = generateConfigYaml(data); generatedConfig = generateConfigYaml(data);
// 持久化到数据库
try {
const payload = {
...data,
soulMd: generatedSoul,
configYaml: generatedConfig,
desc: data.personality.split(/[,]/).slice(0, 2).join(''),
price: parseFloat(data.price) || 29.9,
avatar: data.avatar || `https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=anime%20${encodeURIComponent(data.displayName)}%20portrait&image_size=square`,
temperature: parseFloat(data.temperature) || 0.8,
maxTokens: parseInt(data.maxTokens) || 2048,
};
if (editingRoleId) {
await api(`/roles/${editingRoleId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
await api('/roles', {
method: 'POST',
body: JSON.stringify(payload),
});
}
form.hidden = true; form.hidden = true;
resultPanel.hidden = false; resultPanel.hidden = false;
renderPreview(); renderPreview();
} catch (err) {
alert('保存失败:' + err.message);
}
} }
function renderPreview() { function renderPreview() {
@ -627,8 +717,53 @@ ${data.secrets || '无'}
generatedSoul = ''; generatedSoul = '';
generatedConfig = ''; generatedConfig = '';
activePreview = 'soul'; activePreview = 'soul';
editingRoleId = null;
updateStep(0); updateStep(0);
updateTabs(); updateTabs();
updateSystemPromptPreview();
}
// 加载已有角色数据到表单(编辑模式)
async function loadRoleForEdit(roleId) {
try {
const { role } = await api(`/roles/${roleId}/full`);
editingRoleId = roleId;
form.hidden = false;
resultPanel.hidden = true;
// 填充表单字段
const fields = {
displayName: role.displayName,
gender: role.gender,
age: role.age || '',
relationship: role.relationship || '',
personality: role.personality,
background: role.background,
speechStyle: role.speechStyle,
likes: role.likes || '',
dislikes: role.dislikes || '',
memories: role.memories || '',
secrets: role.secrets || '',
greeting: role.greeting,
systemPrompt: role.systemPrompt || '',
model: role.model,
temperature: String(role.temperature),
maxTokens: String(role.maxTokens),
price: String(role.price),
};
Object.entries(fields).forEach(([name, value]) => {
const el = form.elements[name];
if (el) el.value = value;
});
if (form.elements.enableMemory) form.elements.enableMemory.checked = role.enableMemory;
if (form.elements.enableTools) form.elements.enableTools.checked = role.enableTools;
updateStep(0);
updateTabs();
updateSystemPromptPreview();
} catch (err) {
alert('加载角色数据失败:' + err.message);
}
} }
function updateTabs() { function updateTabs() {
@ -652,7 +787,7 @@ ${data.secrets || '无'}
} }
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', async (e) => {
// FAQ toggle (U4) // FAQ toggle (U4)
const faqBtn = e.target.closest('.faq-q'); const faqBtn = e.target.closest('.faq-q');
if (faqBtn) { if (faqBtn) {
@ -772,7 +907,8 @@ ${data.secrets || '无'}
if (action === 'edit-role') { if (action === 'edit-role') {
e.preventDefault(); e.preventDefault();
resetCreator(); const roleId = target.dataset.roleId;
await loadRoleForEdit(roleId);
showView('creator'); showView('creator');
return; return;
} }
@ -848,14 +984,36 @@ ${data.secrets || '无'}
// --- Auth form submissions --- // --- Auth form submissions ---
document.querySelectorAll('.auth-form').forEach((authForm) => { document.querySelectorAll('.auth-form').forEach((authForm) => {
authForm.addEventListener('submit', (e) => { authForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (!validatePasswordMatch(authForm)) return; if (!validatePasswordMatch(authForm)) return;
const formData = new FormData(authForm); const formData = new FormData(authForm);
const data = Object.fromEntries(formData.entries()); const data = Object.fromEntries(formData.entries());
login(data.account); const isRegister = authForm.dataset.form === 'register';
// After login, go to role library or creator center try {
const endpoint = isRegister ? '/auth/register' : '/auth/login';
const result = await api(endpoint, {
method: 'POST',
body: JSON.stringify({ account: data.account, password: data.password }),
});
setToken(result.token);
applyUserData(result.user);
// 注册成功后自动成为创作者
if (isRegister) {
await api('/auth/settings', {
method: 'PUT',
body: JSON.stringify({ isCreator: true, creatorName: data.account }),
});
state.isCreator = true;
state.creatorName = data.account;
saveState();
updateLandingCard();
}
// 登录后跳转
if (state.isCreator) { if (state.isCreator) {
showView('creator-center'); showView('creator-center');
renderCreatorCenter(); renderCreatorCenter();
@ -863,19 +1021,33 @@ ${data.secrets || '无'}
renderRoleLibrary(); renderRoleLibrary();
showView('role-library'); showView('role-library');
} }
} catch (err) {
alert(err.message);
}
}); });
}); });
// --- Settings form (U7) --- // --- Settings form (U7) ---
document.getElementById('settings-form').addEventListener('submit', (e) => { document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target); const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries()); const data = Object.fromEntries(formData.entries());
state.creatorName = data.creatorName || ''; try {
state.libraryName = data.libraryName || '我的 [XXX]'; const { user } = await api('/auth/settings', {
method: 'PUT',
body: JSON.stringify({
creatorName: data.creatorName,
libraryName: data.libraryName,
}),
});
state.creatorName = user.creatorName || '';
state.libraryName = user.libraryName || '我的 [XXX]';
saveState(); saveState();
updateLandingCard(); updateLandingCard();
alert('设置已保存'); alert('设置已保存');
} catch (err) {
alert('保存失败:' + err.message);
}
}); });
// --- Withdraw form (U7) --- // --- Withdraw form (U7) ---
@ -904,4 +1076,17 @@ ${data.secrets || '无'}
updateTabBar('landing'); updateTabBar('landing');
initFaqA11y(); initFaqA11y();
initViewA11y(); initViewA11y();
// 页面加载时验证 token恢复登录态
(async () => {
const token = getToken();
if (!token) return;
try {
const { user } = await api('/auth/me');
applyUserData(user);
} catch {
// token 过期,清除
setToken('');
}
})();
})(); })();

283
package-lock.json generated
View File

@ -9,7 +9,76 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^5.2.1" "@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"prisma": "^5.22.0"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=16.13"
},
"peerDependencies": {
"prisma": "*"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
}
}
},
"node_modules/@prisma/debug": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@ -25,6 +94,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz",
@ -62,6 +140,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
@ -140,6 +224,23 @@
"node": ">=6.6.0" "node": ">=6.6.0"
} }
}, },
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
@ -166,6 +267,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -180,6 +293,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
@ -322,6 +444,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@ -461,6 +597,91 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -531,6 +752,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
@ -583,6 +813,25 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/prisma": {
"version": "5.22.0",
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
},
"engines": {
"node": ">=16.13"
},
"optionalDependencies": {
"fsevents": "2.3.3"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -651,12 +900,44 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",

View File

@ -5,6 +5,9 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "node --watch server.js",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "jest" "test": "jest"
}, },
"keywords": ["ai", "eternal", "agent", "hermes"], "keywords": ["ai", "eternal", "agent", "hermes"],
@ -15,6 +18,12 @@
"url": "http://gitea.fischerai.cn/chigulong/eternalai.git" "url": "http://gitea.fischerai.cn/chigulong/eternalai.git"
}, },
"dependencies": { "dependencies": {
"express": "^5.2.1" "@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"
} }
} }

70
prisma/schema.prisma Normal file
View File

@ -0,0 +1,70 @@
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[]
}
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,17 +1,27 @@
require('dotenv').config();
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const app = express(); const cors = require('cors');
const PORT = 3001;
// Serve static files from the root directory 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(express.static('.')); app.use(express.static('.'));
// Route for the main page // 主页
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html')); res.sendFile(path.join(__dirname, 'index.html'));
}); });
// Start the server
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`EternalAI server is running on http://0.0.0.0:${PORT}`); console.log(`EternalAI server running on http://0.0.0.0:${PORT}`);
}); });

53
src/lib/auth.js Normal file
View File

@ -0,0 +1,53 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const JWT_SECRET = process.env.JWT_SECRET || 'eternalai_jwt_secret_2026_change_in_prod';
const JWT_EXPIRES_IN = '7d';
// 哈希密码
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 }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
// 验证 JWT 并提取 userId
function verifyToken(token) {
try {
const decoded = jwt.verify(token, JWT_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();
}
module.exports = {
hashPassword,
verifyPassword,
signToken,
verifyToken,
authMiddleware,
};

5
src/lib/prisma.js Normal file
View File

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

128
src/routes/auth.js Normal file
View File

@ -0,0 +1,128 @@
const express = require('express');
const prisma = require('../lib/prisma');
const { hashPassword, verifyPassword, signToken, authMiddleware } = require('../lib/auth');
const router = express.Router();
// 注册
router.post('/register', async (req, res) => {
try {
const { account, password } = req.body;
if (!account || !password) {
return res.status(400).json({ error: '账号和密码不能为空' });
}
if (password.length < 6) {
return res.status(400).json({ error: '密码至少 6 位' });
}
const existing = await prisma.user.findUnique({ where: { account } });
if (existing) {
return res.status(409).json({ error: '该账号已注册' });
}
const user = await prisma.user.create({
data: {
account,
password: hashPassword(password),
libraryName: '我的角色库',
},
});
const token = signToken(user.id);
res.json({
token,
user: {
id: user.id,
account: user.account,
isCreator: user.isCreator,
creatorName: user.creatorName,
libraryName: user.libraryName,
},
});
} catch (err) {
console.error('注册失败:', err);
res.status(500).json({ error: '注册失败,请稍后重试' });
}
});
// 登录
router.post('/login', async (req, res) => {
try {
const { account, password } = req.body;
if (!account || !password) {
return res.status(400).json({ error: '账号和密码不能为空' });
}
const user = await prisma.user.findUnique({ where: { account } });
if (!user || !verifyPassword(password, user.password)) {
return res.status(401).json({ error: '账号或密码错误' });
}
const token = signToken(user.id);
res.json({
token,
user: {
id: user.id,
account: user.account,
isCreator: user.isCreator,
creatorName: user.creatorName,
libraryName: user.libraryName,
},
});
} catch (err) {
console.error('登录失败:', err);
res.status(500).json({ error: '登录失败,请稍后重试' });
}
});
// 获取当前用户信息
router.get('/me', authMiddleware, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: {
id: true,
account: true,
isCreator: true,
creatorName: true,
libraryName: true,
boundCreator: true,
},
});
if (!user) {
return res.status(404).json({ error: '用户不存在' });
}
res.json({ user });
} catch (err) {
console.error('获取用户信息失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 更新用户设置
router.put('/settings', authMiddleware, async (req, res) => {
try {
const { creatorName, libraryName, isCreator } = req.body;
const user = await prisma.user.update({
where: { id: req.userId },
data: {
...(creatorName !== undefined && { creatorName }),
...(libraryName !== undefined && { libraryName }),
...(isCreator !== undefined && { isCreator }),
},
select: {
id: true,
account: true,
isCreator: true,
creatorName: true,
libraryName: true,
},
});
res.json({ user });
} catch (err) {
console.error('更新设置失败:', err);
res.status(500).json({ error: '更新失败' });
}
});
module.exports = router;

187
src/routes/roles.js Normal file
View File

@ -0,0 +1,187 @@
const express = require('express');
const prisma = require('../lib/prisma');
const { authMiddleware } = require('../lib/auth');
const router = express.Router();
// 获取角色库(所有已上架角色)
router.get('/', async (req, res) => {
try {
const roles = await prisma.role.findMany({
where: { status: 'running' },
orderBy: { createdAt: 'desc' },
select: {
id: true,
displayName: true,
avatar: true,
desc: true,
price: true,
status: true,
},
});
res.json({ roles });
} catch (err) {
console.error('获取角色列表失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 获取角色详情
router.get('/:id', async (req, res) => {
try {
const role = await prisma.role.findUnique({
where: { id: req.params.id },
select: {
id: true,
displayName: true,
avatar: true,
desc: true,
price: true,
status: true,
gender: true,
age: true,
relationship: true,
personality: true,
background: true,
speechStyle: true,
greeting: true,
creatorId: true,
},
});
if (!role) {
return res.status(404).json({ error: '角色不存在' });
}
res.json({ role });
} catch (err) {
console.error('获取角色详情失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 获取当前用户创建的角色
router.get('/my/roles', authMiddleware, async (req, res) => {
try {
const roles = await prisma.role.findMany({
where: { creatorId: req.userId },
orderBy: { createdAt: 'desc' },
});
res.json({ roles });
} catch (err) {
console.error('获取我的角色失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
// 发布新角色
router.post('/', authMiddleware, async (req, res) => {
try {
const data = req.body;
if (!data.displayName || !data.greeting || !data.personality || !data.background || !data.speechStyle) {
return res.status(400).json({ error: '必填字段缺失' });
}
const role = await prisma.role.create({
data: {
creatorId: req.userId,
displayName: data.displayName,
gender: data.gender || 'unknown',
age: data.age || null,
relationship: data.relationship || null,
personality: data.personality,
background: data.background,
speechStyle: data.speechStyle,
likes: data.likes || null,
dislikes: data.dislikes || null,
memories: data.memories || null,
secrets: data.secrets || null,
greeting: data.greeting,
systemPrompt: data.systemPrompt || null,
model: data.model || 'gpt-4o',
temperature: parseFloat(data.temperature) || 0.8,
maxTokens: parseInt(data.maxTokens) || 2048,
enableMemory: data.enableMemory ?? true,
enableTools: data.enableTools ?? false,
agentId: data.agentId || null,
soulMd: data.soulMd || null,
configYaml: data.configYaml || null,
avatar: data.avatar || null,
desc: data.desc || data.personality.slice(0, 50),
price: parseFloat(data.price) || 0,
status: 'running',
},
});
res.json({ role });
} catch (err) {
console.error('发布角色失败:', err);
res.status(500).json({ error: '发布失败' });
}
});
// 编辑角色
router.put('/:id', authMiddleware, async (req, res) => {
try {
const existing = await prisma.role.findUnique({ where: { id: req.params.id } });
if (!existing) {
return res.status(404).json({ error: '角色不存在' });
}
if (existing.creatorId !== req.userId) {
return res.status(403).json({ error: '无权编辑他人角色' });
}
const data = req.body;
const role = await prisma.role.update({
where: { id: req.params.id },
data: {
displayName: data.displayName ?? existing.displayName,
gender: data.gender ?? existing.gender,
age: data.age ?? existing.age,
relationship: data.relationship ?? existing.relationship,
personality: data.personality ?? existing.personality,
background: data.background ?? existing.background,
speechStyle: data.speechStyle ?? existing.speechStyle,
likes: data.likes ?? existing.likes,
dislikes: data.dislikes ?? existing.dislikes,
memories: data.memories ?? existing.memories,
secrets: data.secrets ?? existing.secrets,
greeting: data.greeting ?? existing.greeting,
systemPrompt: data.systemPrompt ?? existing.systemPrompt,
model: data.model ?? existing.model,
temperature: parseFloat(data.temperature) || existing.temperature,
maxTokens: parseInt(data.maxTokens) || existing.maxTokens,
enableMemory: data.enableMemory ?? existing.enableMemory,
enableTools: data.enableTools ?? existing.enableTools,
soulMd: data.soulMd ?? existing.soulMd,
configYaml: data.configYaml ?? existing.configYaml,
avatar: data.avatar ?? existing.avatar,
desc: data.desc ?? existing.desc,
price: parseFloat(data.price) || existing.price,
status: data.status ?? existing.status,
},
});
res.json({ role });
} catch (err) {
console.error('编辑角色失败:', err);
res.status(500).json({ error: '编辑失败' });
}
});
// 获取角色详情(含 Soul.md 和 config.yaml仅创建者可访问
router.get('/:id/full', authMiddleware, async (req, res) => {
try {
const role = await prisma.role.findUnique({ where: { id: req.params.id } });
if (!role) {
return res.status(404).json({ error: '角色不存在' });
}
if (role.creatorId !== req.userId) {
return res.status(403).json({ error: '无权查看' });
}
res.json({ role });
} catch (err) {
console.error('获取角色完整信息失败:', err);
res.status(500).json({ error: '服务器错误' });
}
});
module.exports = router;