|
|
import express from 'express'; |
|
|
import cors from 'cors'; |
|
|
import multer from 'multer'; |
|
|
import fetch from 'node-fetch'; |
|
|
import path from 'path'; |
|
|
import { fileURLToPath } from 'url'; |
|
|
import fs from 'fs'; |
|
|
import extract from 'png-chunks-extract'; |
|
|
import text from 'png-chunk-text'; |
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url); |
|
|
const __dirname = path.dirname(__filename); |
|
|
|
|
|
const app = express(); |
|
|
const upload = multer({ storage: multer.memoryStorage() }); |
|
|
|
|
|
|
|
|
app.use(cors()); |
|
|
app.use(express.json()); |
|
|
app.use(express.static('public')); |
|
|
|
|
|
|
|
|
app.post('/api/chat', async (req, res) => { |
|
|
try { |
|
|
const { apiKey, model, messages, temperature, maxTokens, topP, repetitionPenalty } = req.body; |
|
|
|
|
|
const response = await fetch( |
|
|
'https://www.gpt4novel.com/api/xiaoshuoai/ext/v1/chat/completions', |
|
|
{ |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
'Authorization': `Bearer ${apiKey}`, |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
model: model, |
|
|
messages: messages, |
|
|
stream: true, |
|
|
temperature: temperature || 0.7, |
|
|
max_tokens: maxTokens || 800, |
|
|
top_p: topP || 0.35, |
|
|
repetition_penalty: repetitionPenalty || 1.05, |
|
|
}), |
|
|
} |
|
|
); |
|
|
|
|
|
if (!response.ok) { |
|
|
return res.status(response.status).json({ error: `API请求失败: ${response.status}` }); |
|
|
} |
|
|
|
|
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream'); |
|
|
res.setHeader('Cache-Control', 'no-cache'); |
|
|
res.setHeader('Connection', 'keep-alive'); |
|
|
|
|
|
|
|
|
response.body.on('data', (chunk) => { |
|
|
res.write(chunk); |
|
|
}); |
|
|
|
|
|
response.body.on('end', () => { |
|
|
res.end(); |
|
|
}); |
|
|
|
|
|
response.body.on('error', (error) => { |
|
|
console.error('Stream error:', error); |
|
|
res.end(); |
|
|
}); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Chat error:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.get('/api/models', async (req, res) => { |
|
|
try { |
|
|
const apiKey = req.headers.authorization?.replace('Bearer ', ''); |
|
|
|
|
|
if (!apiKey) { |
|
|
return res.status(401).json({ error: '需要API密钥' }); |
|
|
} |
|
|
|
|
|
|
|
|
const models = [ |
|
|
{ id: 'nalang-max-0826-16k', name: 'Nalang Max 16K (推荐)', price: '$0.0004/1K tokens' }, |
|
|
{ id: 'nalang-max-0826-10k', name: 'Nalang Max 10K', price: '$0.0004/1K tokens' }, |
|
|
{ id: 'nalang-max-0826', name: 'Nalang Max 32K', price: '$0.0004/1K tokens' }, |
|
|
{ id: 'nalang-xl-0826-16k', name: 'Nalang XL 16K (推荐)', price: '$0.0003/1K tokens' }, |
|
|
{ id: 'nalang-xl-0826-10k', name: 'Nalang XL 10K', price: '$0.0003/1K tokens' }, |
|
|
{ id: 'nalang-xl-0826', name: 'Nalang XL 32K', price: '$0.0003/1K tokens' }, |
|
|
{ id: 'nalang-medium-0826', name: 'Nalang Medium 32K', price: '$0.0002/1K tokens' }, |
|
|
{ id: 'nalang-turbo-0826', name: 'Nalang Turbo 32K (推荐)', price: '$0.0001/1K tokens' }, |
|
|
]; |
|
|
|
|
|
res.json({ models }); |
|
|
} catch (error) { |
|
|
console.error('Models error:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.post('/api/comfyui/prompt', async (req, res) => { |
|
|
try { |
|
|
const { comfyuiUrl, workflow, prompt } = req.body; |
|
|
|
|
|
if (!comfyuiUrl || !workflow) { |
|
|
return res.status(400).json({ error: '缺少必要参数' }); |
|
|
} |
|
|
|
|
|
|
|
|
const modifiedWorkflow = JSON.parse(JSON.stringify(workflow)); |
|
|
|
|
|
|
|
|
for (const nodeId in modifiedWorkflow) { |
|
|
const node = modifiedWorkflow[nodeId]; |
|
|
if (node.class_type === 'CLIPTextEncode' && node.inputs) { |
|
|
node.inputs.text = prompt; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const response = await fetch(`${comfyuiUrl}/prompt`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
'Content-Type': 'application/json', |
|
|
'Accept': 'application/json', |
|
|
}, |
|
|
body: JSON.stringify({ |
|
|
prompt: modifiedWorkflow, |
|
|
}), |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
console.error('ComfyUI错误响应:', errorText); |
|
|
throw new Error(`ComfyUI请求失败: ${response.status} - ${errorText}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
res.json(data); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('ComfyUI error:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.get('/api/comfyui/history/:promptId', async (req, res) => { |
|
|
try { |
|
|
const { comfyuiUrl } = req.query; |
|
|
const { promptId } = req.params; |
|
|
|
|
|
|
|
|
if (!comfyuiUrl) { |
|
|
console.error('缺少ComfyUI URL参数'); |
|
|
return res.status(400).json({ error: '缺少ComfyUI URL' }); |
|
|
} |
|
|
|
|
|
if (!promptId) { |
|
|
console.error('缺少promptId参数'); |
|
|
return res.status(400).json({ error: '缺少promptId' }); |
|
|
} |
|
|
|
|
|
console.log(`获取历史: ${comfyuiUrl}/history/${promptId}`); |
|
|
|
|
|
const response = await fetch(`${comfyuiUrl}/history/${promptId}`, { |
|
|
headers: { |
|
|
'Accept': 'application/json', |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
const errorText = await response.text(); |
|
|
console.error(`ComfyUI历史请求失败: ${response.status}`, errorText); |
|
|
throw new Error(`获取历史失败: ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
res.json(data); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('History error:', error); |
|
|
res.status(500).json({ |
|
|
error: error.message, |
|
|
details: '无法从ComfyUI获取生成历史,请检查ComfyUI是否正常运行' |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.get('/api/comfyui/view', async (req, res) => { |
|
|
try { |
|
|
const { comfyuiUrl, filename, subfolder, type } = req.query; |
|
|
|
|
|
if (!comfyuiUrl || !filename) { |
|
|
return res.status(400).json({ error: '缺少必要参数' }); |
|
|
} |
|
|
|
|
|
|
|
|
let imageUrl = `${comfyuiUrl}/view?filename=${encodeURIComponent(filename)}`; |
|
|
if (subfolder) { |
|
|
imageUrl += `&subfolder=${encodeURIComponent(subfolder)}`; |
|
|
} |
|
|
if (type) { |
|
|
imageUrl += `&type=${encodeURIComponent(type)}`; |
|
|
} |
|
|
|
|
|
const response = await fetch(imageUrl); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`获取图片失败: ${response.status}`); |
|
|
} |
|
|
|
|
|
|
|
|
const contentType = response.headers.get('content-type') || 'image/png'; |
|
|
res.setHeader('Content-Type', contentType); |
|
|
res.setHeader('Access-Control-Allow-Origin', '*'); |
|
|
|
|
|
response.body.pipe(res); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('View image error:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.get('/api/comfyui/test', async (req, res) => { |
|
|
try { |
|
|
const { url } = req.query; |
|
|
|
|
|
if (!url) { |
|
|
return res.status(400).json({ success: false, error: '缺少URL参数' }); |
|
|
} |
|
|
|
|
|
|
|
|
const response = await fetch(`${url}/system_stats`, { |
|
|
headers: { |
|
|
'Accept': 'application/json', |
|
|
} |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`连接失败: HTTP ${response.status}`); |
|
|
} |
|
|
|
|
|
const data = await response.json(); |
|
|
res.json({ |
|
|
success: true, |
|
|
data, |
|
|
message: 'ComfyUI连接成功!' |
|
|
}); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Test connection error:', error); |
|
|
|
|
|
|
|
|
let errorMessage = error.message; |
|
|
if (error.code === 'ECONNREFUSED') { |
|
|
errorMessage = '无法连接到ComfyUI服务器,请检查:\n1. ComfyUI是否正在运行\n2. URL地址是否正确\n3. 端口号是否正确(默认8188)'; |
|
|
} else if (error.message.includes('403')) { |
|
|
errorMessage = 'ComfyUI拒绝连接(403),请在ComfyUI启动时添加 --listen 参数'; |
|
|
} else if (error.message.includes('CORS')) { |
|
|
errorMessage = 'CORS跨域错误,ComfyUI需要允许跨域访问'; |
|
|
} |
|
|
|
|
|
res.json({ |
|
|
success: false, |
|
|
error: errorMessage, |
|
|
details: error.message |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.post('/api/parse-character-png', upload.single('file'), async (req, res) => { |
|
|
try { |
|
|
if (!req.file) { |
|
|
return res.status(400).json({ error: '没有上传文件' }); |
|
|
} |
|
|
|
|
|
const buffer = req.file.buffer; |
|
|
const chunks = extract(buffer); |
|
|
|
|
|
|
|
|
let characterData = null; |
|
|
|
|
|
for (const chunk of chunks) { |
|
|
if (chunk.name === 'tEXt') { |
|
|
const textData = text.decode(chunk.data); |
|
|
if (textData.keyword === 'chara' || textData.keyword === 'character') { |
|
|
try { |
|
|
|
|
|
const decoded = Buffer.from(textData.text, 'base64').toString('utf-8'); |
|
|
characterData = JSON.parse(decoded); |
|
|
break; |
|
|
} catch (e) { |
|
|
console.error('解析角色数据失败:', e); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (characterData) { |
|
|
res.json({ success: true, character: characterData }); |
|
|
} else { |
|
|
res.status(400).json({ error: '未找到角色卡数据' }); |
|
|
} |
|
|
|
|
|
} catch (error) { |
|
|
console.error('Parse PNG error:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
app.post('/api/generate-pdf', async (req, res) => { |
|
|
try { |
|
|
const { title, chapters, style } = req.body; |
|
|
|
|
|
if (!title || !chapters) { |
|
|
return res.status(400).json({ error: '缺少必要参数' }); |
|
|
} |
|
|
|
|
|
|
|
|
const { default: PDFDocument } = await import('pdfkit'); |
|
|
const { default: fetch } = await import('node-fetch'); |
|
|
|
|
|
|
|
|
const doc = new PDFDocument({ |
|
|
size: 'A4', |
|
|
margins: { top: 50, bottom: 50, left: 50, right: 50 } |
|
|
}); |
|
|
|
|
|
|
|
|
res.setHeader('Content-Type', 'application/pdf'); |
|
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(title)}.pdf"`); |
|
|
|
|
|
|
|
|
doc.pipe(res); |
|
|
|
|
|
|
|
|
const styles = { |
|
|
modern: { |
|
|
titleFont: 'Helvetica-Bold', |
|
|
titleSize: 32, |
|
|
headingFont: 'Helvetica-Bold', |
|
|
headingSize: 20, |
|
|
bodyFont: 'Helvetica', |
|
|
bodySize: 12, |
|
|
lineHeight: 1.5 |
|
|
}, |
|
|
classic: { |
|
|
titleFont: 'Times-Bold', |
|
|
titleSize: 36, |
|
|
headingFont: 'Times-Bold', |
|
|
headingSize: 22, |
|
|
bodyFont: 'Times-Roman', |
|
|
bodySize: 13, |
|
|
lineHeight: 1.6 |
|
|
}, |
|
|
magazine: { |
|
|
titleFont: 'Helvetica-Bold', |
|
|
titleSize: 40, |
|
|
headingFont: 'Helvetica-Bold', |
|
|
headingSize: 24, |
|
|
bodyFont: 'Helvetica', |
|
|
bodySize: 11, |
|
|
lineHeight: 1.4 |
|
|
}, |
|
|
technical: { |
|
|
titleFont: 'Courier-Bold', |
|
|
titleSize: 28, |
|
|
headingFont: 'Courier-Bold', |
|
|
headingSize: 18, |
|
|
bodyFont: 'Courier', |
|
|
bodySize: 10, |
|
|
lineHeight: 1.5 |
|
|
} |
|
|
}; |
|
|
|
|
|
const currentStyle = styles[style] || styles.modern; |
|
|
|
|
|
|
|
|
doc.font(currentStyle.titleFont) |
|
|
.fontSize(currentStyle.titleSize) |
|
|
.text(title, 50, 300, { align: 'center' }); |
|
|
|
|
|
doc.fontSize(14) |
|
|
.font('Helvetica') |
|
|
.text(`生成于 ${new Date().toLocaleDateString('zh-CN')}`, 50, 400, { align: 'center' }); |
|
|
|
|
|
doc.addPage(); |
|
|
|
|
|
|
|
|
for (let i = 0; i < chapters.length; i++) { |
|
|
const chapter = chapters[i]; |
|
|
|
|
|
|
|
|
if (i > 0) { |
|
|
doc.addPage(); |
|
|
} |
|
|
|
|
|
doc.font(currentStyle.headingFont) |
|
|
.fontSize(currentStyle.headingSize) |
|
|
.text(chapter.title, { align: 'left' }); |
|
|
|
|
|
doc.moveDown(1); |
|
|
|
|
|
|
|
|
doc.font(currentStyle.bodyFont) |
|
|
.fontSize(currentStyle.bodySize); |
|
|
|
|
|
|
|
|
const cleanContent = chapter.content.replace(/\[IMAGE:.*?\]/g, ''); |
|
|
const paragraphs = cleanContent.split('\n').filter(p => p.trim()); |
|
|
|
|
|
for (const paragraph of paragraphs) { |
|
|
doc.text(paragraph, { |
|
|
align: 'justify', |
|
|
lineGap: currentStyle.lineHeight * 2 |
|
|
}); |
|
|
doc.moveDown(0.5); |
|
|
} |
|
|
|
|
|
|
|
|
if (chapter.images && chapter.images.length > 0) { |
|
|
for (const imageUrl of chapter.images) { |
|
|
try { |
|
|
|
|
|
const imageResponse = await fetch(imageUrl); |
|
|
const imageBuffer = await imageResponse.buffer(); |
|
|
|
|
|
doc.moveDown(1); |
|
|
|
|
|
|
|
|
const pageWidth = doc.page.width - 100; |
|
|
doc.image(imageBuffer, 50, doc.y, { |
|
|
fit: [pageWidth, 300], |
|
|
align: 'center' |
|
|
}); |
|
|
|
|
|
doc.moveDown(1); |
|
|
} catch (error) { |
|
|
console.error('添加图片失败:', error); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
doc.end(); |
|
|
|
|
|
} catch (error) { |
|
|
console.error('PDF生成错误:', error); |
|
|
res.status(500).json({ error: error.message }); |
|
|
} |
|
|
}); |
|
|
|
|
|
const PORT = process.env.PORT || 7860; |
|
|
|
|
|
app.listen(PORT, () => { |
|
|
console.log(`服务器运行在端口 ${PORT}`); |
|
|
}); |