/** * 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 };