Newnai2 / server.js
Logankunfall's picture
Upload 21 files
6ddb2f2 verified
/**
* New NAI - Node.js/Express 服务
* 目标:保持与现有 FastAPI 接口一致,使前端无需改动即可运行。
*
* 实现接口:
* - GET /api/health
* - GET /api/config
* - PUT /api/config
* - GET /api/select-output-dir (本地桌面环境:弹出目录选择器;无 GUI 环境会失败)
* - POST /api/open-dir (打开目录,若未传 path 则读取配置中的 output_dir)
*
* 静态资源:
* - / -> ./frontend (index.html 单页)
* - /ring -> ./ring (提示音目录)
* - 兼容别名:/ring/ring.mp3 若实际不存在则回退到 new-notification-3-398649.mp3
*
* 注意:本文件尚未实现 /api/generate/* 生成接口;将在后续步骤补充。
*/
const path = require('path');
const fs = require('fs');
const fse = require('fs-extra');
const os = require('os');
const http = require('http');
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const { spawn } = require('child_process');
const { generateT2I, generateI2I, generateInpaint } = require('./novelai');
const app = express();
// ---------- 常量与路径 ----------
const ROOT = __dirname;
const FRONTEND_DIR = path.join(ROOT, 'frontend');
const RING_DIR = path.join(ROOT, 'ring');
const IMAGE_DIR = path.join(ROOT, 'image');
const IMAGE2_DIR = path.join(ROOT, 'image2');
const CONFIG_PATH = path.join(ROOT, 'backend', 'config.json');
// ---------- 中间件 ----------
app.disable('x-powered-by');
app.use(cors({
origin: '*',
credentials: false,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key']
}));
// Helmet 安全头(简化版 CSP,允许被同源 iframe 嵌入;可按需扩展)
app.use(helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
"default-src": ["'self'"],
"img-src": ["'self'", "data:", "blob:"],
"style-src": ["'self'", "'unsafe-inline'"],
"script-src": ["'self'"],
"font-src": ["'self'", "data:"],
// 允许在 HF 页面中嵌入,以及前端与同源/HF 域通信
"connect-src": ["'self'", "https://*.hf.space", "https://huggingface.co"],
"frame-ancestors": ["'self'", "https://*.hf.space", "https://huggingface.co"],
}
},
crossOriginResourcePolicy: { policy: "same-origin" }
}));
app.use(compression());
app.use(express.json({ limit: '50mb' })); // 前端传参与图片 Base64,放宽到 50MB
app.use(morgan('dev'));
// ---------- 工具函数:配置读写与默认值 ----------
const DEFAULT_CONFIG = {
key: null,
model: "nai-diffusion-3",
sampler: "k_euler",
steps: 28,
scale: 5.0,
cfg_rescale: 0.0,
noise_schedule: "karras",
uc_preset: 4,
quality_toggle: true,
legacy_uc: false,
port: 7860,
save_output: true,
output_dir: path.join(ROOT, 'output'),
// 提示音配置
sound_enabled: false,
sound_url: "/ring/ring.mp3"
};
let CURRENT_CFG = null; // HF: 内存态配置,避免写盘触发重启
function readConfig() {
// HF 环境:优先使用内存态,避免文件写入引发 nodemon/watchdog 重启
if (CURRENT_CFG) return { ...CURRENT_CFG };
try {
if (fs.existsSync(CONFIG_PATH)) {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
const fileCfg = JSON.parse(raw || '{}');
CURRENT_CFG = { ...DEFAULT_CONFIG, ...fileCfg };
return { ...CURRENT_CFG };
}
} catch (e) {
// ignore and fallback
}
CURRENT_CFG = { ...DEFAULT_CONFIG };
return { ...CURRENT_CFG };
}
function writeConfig(cfg) {
// HF 环境:仅更新内存态,不写盘,避免引发容器内重启/热更新
const merged = { ...DEFAULT_CONFIG, ...(cfg || {}) };
CURRENT_CFG = merged;
return { ...merged };
}
// ---------- 工具函数:系统命令 ----------
function runSpawn(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
...options
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d.toString(); });
child.stderr.on('data', (d) => { stderr += d.toString(); });
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve({ stdout, stderr, code });
else reject(new Error(stderr || `Exit code ${code}`));
});
});
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// Windows: 使用 COM Shell.Application 弹出目录选择器
async function winBrowseForFolder() {
// 方案1:PowerShell COM
try {
const psCmd = `$f=(New-Object -ComObject Shell.Application).BrowseForFolder(0,"选择保存目录",0); if($f){$f.Self.Path}`;
const { stdout } = await runSpawn('powershell.exe', ['-NoProfile', '-Command', psCmd], { timeout: 60000 });
const out = (stdout || '').trim();
if (out && out.length > 0) return out;
} catch (e) {
// 继续尝试下一个方案
}
// 方案2:VBScript 兜底
try {
const vbs = [
'Set sh = CreateObject("Shell.Application")',
'Set f = sh.BrowseForFolder(0, "选择保存目录", 0)',
'If (Not f Is Nothing) Then',
' WScript.Echo f.Self.Path',
'End If'
].join('\n');
const osTmp = os.tmpdir();
const vbsPath = path.join(osTmp, `browse_${Date.now()}.vbs`);
fs.writeFileSync(vbsPath, vbs, 'utf-8');
try {
const { stdout } = await runSpawn('cscript.exe', ['//nologo', vbsPath], { timeout: 60000 });
const out = (stdout || '').trim();
if (out && out.length > 0) return out;
} finally {
try { fs.unlinkSync(vbsPath); } catch {}
}
} catch (e) {
// 继续尝试下一个方案
}
throw new Error('Windows 目录选择失败:所有方案均失败');
}
async function darwinChooseFolder() {
const script = 'tell application "System Events" to POSIX path of (choose folder with prompt "选择保存目录")';
const { stdout } = await runSpawn('osascript', ['-e', script], { timeout: 60000 });
const out = (stdout || '').trim();
if (out) return out;
throw new Error('macOS 目录选择失败');
}
async function linuxZenity() {
const { stdout } = await runSpawn('zenity', ['--file-selection', '--directory', '--title=选择保存目录'], { timeout: 60000 });
const out = (stdout || '').trim();
if (out) return out;
throw new Error('Linux 目录选择失败 / zenity 不可用');
}
async function pickDirectory() {
const sys = os.platform(); // win32, darwin, linux
if (sys === 'win32') {
return await winBrowseForFolder();
} else if (sys === 'darwin') {
return await darwinChooseFolder();
} else if (sys === 'linux') {
return await linuxZenity();
}
throw new Error(`不支持的系统平台:${sys}`);
}
async function openDirectory(p) {
const sys = os.platform();
const absPath = path.resolve(p);
// 确保目录存在
await fse.ensureDir(absPath);
if (sys === 'win32') {
// Windows: 使用 explorer
try {
await runSpawn('explorer.exe', [absPath]);
} catch (e) {
// 回退方案:使用 cmd start
spawn('cmd', ['/c', 'start', '', absPath], { detached: true, stdio: 'ignore' }).unref();
}
} else if (sys === 'darwin') {
await runSpawn('open', [absPath]);
} else {
// Linux
await runSpawn('xdg-open', [absPath]);
}
}
// ---------- API 路由 ----------
// 健康检查
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// 获取配置
app.get('/api/config', (req, res) => {
try {
const cfg = readConfig();
res.json(cfg);
} catch (e) {
res.status(500).json({ detail: String(e && e.message || e) });
}
});
// 更新配置(部分字段)
app.put('/api/config', (req, res) => {
try {
const current = readConfig();
const body = req.body || {};
// 仅合并非 undefined 的字段;允许 null 写入(表示清空)
const next = { ...current };
for (const k of Object.keys(body)) {
next[k] = body[k];
}
const saved = writeConfig(next);
res.json(saved);
} catch (e) {
res.status(500).json({ detail: String(e && e.message || e) });
}
});
// 选择输出目录(桌面环境)
app.get('/api/select-output-dir', async (req, res) => {
// HF 环境不支持系统目录选择,直接返回 501,前端已做兜底提示
return res.status(501).json({ detail: 'HF 环境不支持目录选择,请在“保存目录”中手动填写或留空。' });
});
// 打开目录
app.post('/api/open-dir', async (req, res) => {
// HF 环境不支持打开系统目录,统一返回 501
return res.status(501).json({ detail: 'HF 环境不支持打开系统目录(不影响生成与下载)。' });
});
// 生成接口:T2I
app.post('/api/generate/t2i', async (req, res) => {
try {
const cfg = readConfig();
if (!cfg.key) {
return res.status(400).json({ detail: '尚未配置 key,请先在配置中设置 key。' });
}
const b = req.body || {};
const { dataUri, savedPath } = await generateT2I(cfg, {
prompt: b.prompt,
negative: b.negative || '',
width: b.width ?? 768,
height: b.height ?? 768,
scale: b.scale ?? null,
steps: b.steps ?? null,
sampler: b.sampler ?? null,
noise_schedule: b.noise_schedule ?? null,
seed: b.seed ?? -1,
variety: !!b.variety,
decrisp: !!b.decrisp,
cfg_rescale: b.cfg_rescale ?? null,
});
return res.json({ image_base64: dataUri, saved_path: savedPath });
} catch (e) {
return res.status(500).json({ detail: String((e && e.message) || e) });
}
});
// 生成接口:I2I
app.post('/api/generate/i2i', async (req, res) => {
try {
const cfg = readConfig();
if (!cfg.key) {
return res.status(400).json({ detail: '尚未配置 key,请先在配置中设置 key。' });
}
const b = req.body || {};
const { dataUri, savedPath } = await generateI2I(cfg, {
positive: b.positive || '',
negative: b.negative || '',
image_base64: b.image_base64,
width: b.width ?? null,
height: b.height ?? null,
scale: b.scale ?? null,
steps: b.steps ?? null,
sampler: b.sampler ?? null,
noise_schedule: b.noise_schedule ?? null,
strength: b.strength ?? 0.5,
noise: b.noise ?? 0.0,
seed: b.seed ?? -1,
variety: !!b.variety,
decrisp: !!b.decrisp,
cfg_rescale: b.cfg_rescale ?? null,
});
return res.json({ image_base64: dataUri, saved_path: savedPath });
} catch (e) {
return res.status(500).json({ detail: String((e && e.message) || e) });
}
});
// 生成接口:Inpaint
app.post('/api/generate/inpaint', async (req, res) => {
try {
const cfg = readConfig();
if (!cfg.key) {
return res.status(400).json({ detail: '尚未配置 key,请先在配置中设置 key。' });
}
const b = req.body || {};
const { dataUri, savedPath } = await generateInpaint(cfg, {
positive: b.positive || '',
negative: b.negative || '',
image_base64: b.image_base64,
mask_base64: b.mask_base64,
add_original_image: !!b.add_original_image,
width: b.width ?? null,
height: b.height ?? null,
scale: b.scale ?? null,
steps: b.steps ?? null,
sampler: b.sampler ?? null,
noise_schedule: b.noise_schedule ?? null,
strength: b.strength ?? 0.5,
noise: b.noise ?? 0.0,
seed: b.seed ?? -1,
variety: !!b.variety,
decrisp: !!b.decrisp,
cfg_rescale: b.cfg_rescale ?? null,
});
return res.json({ image_base64: dataUri, saved_path: savedPath });
} catch (e) {
return res.status(500).json({ detail: String((e && e.message) || e) });
}
});
// favicon 占位,避免某些浏览器 404 导致干扰
app.get('/favicon.ico', (req, res) => res.status(204).end());
// ---------- 静态资源 ----------
// 背景图片目录(电脑端)
app.use('/image', express.static(IMAGE_DIR, { fallthrough: true }));
// 背景图片目录(手机端)
app.use('/image2', express.static(IMAGE2_DIR, { fallthrough: true }));
// 铃声目录
app.use('/ring', express.static(RING_DIR, { fallthrough: true }));
// 兼容别名:/ring/ring.mp3 -> 若 ring.mp3 不存在则回退到 new-notification-3-398649.mp3
app.get('/ring/ring.mp3', (req, res, next) => {
const main = path.join(RING_DIR, 'ring.mp3');
const alt = path.join(RING_DIR, 'new-notification-3-398649.mp3');
if (fs.existsSync(main)) return res.sendFile(main);
if (fs.existsSync(alt)) return res.sendFile(alt);
return res.status(404).end();
});
// 前端静态资源(单页)
app.use('/', express.static(FRONTEND_DIR, { index: 'index.html' }));
// ---------- 启动服务 ----------
function resolvePort() {
// HF Docker 要求监听 $PORT(若无则回退 7860)
const envPort = parseInt(process.env.PORT || '', 10);
if (!Number.isNaN(envPort) && envPort > 0) return envPort;
return 7860;
}
const PORT = resolvePort();
// HF 必须使用 0.0.0.0 以便外部访问
const HOST = process.env.HOST || '0.0.0.0';
const server = http.createServer(app);
server.listen(PORT, HOST, () => {
// HF 环境显示 0.0.0.0,但实际通过 Space URL 访问
const displayUrl = HOST === '0.0.0.0' ? `http://0.0.0.0:${PORT}` : `http://${HOST}:${PORT}`;
// HF 环境不自动打开浏览器
const shouldOpen = process.env.AUTO_OPEN_BROWSER !== '0' && HOST === '127.0.0.1';
if (shouldOpen) {
setTimeout(() => {
try {
const localUrl = `http://127.0.0.1:${PORT}`;
const platform = os.platform();
if (platform === 'win32') {
spawn('cmd', ['/c', 'start', '', localUrl], { detached: true, stdio: 'ignore' }).unref();
} else if (platform === 'darwin') {
spawn('open', [localUrl], { detached: true, stdio: 'ignore' }).unref();
} else {
spawn('xdg-open', [localUrl], { detached: true, stdio: 'ignore' }).unref();
}
} catch { /* ignore */ }
}, 1500);
}
// eslint-disable-next-line no-console
console.log(`[New NAI HF] 服务运行于 ${displayUrl}`);
console.log(`[New NAI HF] ${HOST === '0.0.0.0' ? 'HF Space 通过公共 URL 访问' : '按 Ctrl+C 停止服务'}`);
});
// 导出 app 以便测试或其他入口使用
module.exports = { app };