From 2837bc1a8508774657e9a95b33043a2046e0eace Mon Sep 17 00:00:00 2001 From: zwbcc Date: Wed, 25 Mar 2026 23:49:47 +0800 Subject: [PATCH] init: image-generator with MiniMax image-01 support --- CHANGELOG.md | 13 ++ app.js | 115 ++++++++++++ index.html | 370 ++++++++++++++++++++++++++++++++++++ start.sh | 4 + style.css | 518 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1020 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 app.js create mode 100644 index.html create mode 100755 start.sh create mode 100644 style.css diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effc0c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# CHANGELOG + +## 2026-03-25 + +### Added +- **image-generator** - MiniMax image-01 文生图网页 + - 地址:`http://10.0.10.110:8195` + - 支持选择比例(1:1 / 16:9 / 4:3 / 3:2 / 2:3 / 3:4 / 9:16 / 21:9) + - 支持 prompt 输入(最多 1500 字符) + - 可下载生成图片 / 复制 Base64 + - API Key 配置在 Settings 里(服务端存储,不暴露) + - 依赖:express, node-fetch(全局 npm) + - systemd service:`image-generator.service` diff --git a/app.js b/app.js new file mode 100644 index 0000000..06d291d --- /dev/null +++ b/app.js @@ -0,0 +1,115 @@ +'use strict'; + +// ============================================================ +// Config +// ============================================================ + +const express = require('express'); +const fetch = require('node-fetch'); +const path = require('path'); +const fs = require('fs'); + +const PORT = 8195; +const HOST = '0.0.0.0'; + +// Config file stored alongside app.js +const CONFIG_FILE = path.join(__dirname, 'config.json'); + +function loadConfig() { + try { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + } catch { + return { apiKey: '', baseUrl: 'https://api.minimax.io' }; + } +} + +function saveConfig(cfg) { + fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2)); +} + +// ============================================================ +// App +// ============================================================ + +const app = express(); +app.use(express.json()); +app.use(express.static(__dirname)); + +// --- Settings --- + +app.get('/api/config', (req, res) => { + const cfg = loadConfig(); + res.json({ + hasApiKey: !!cfg.apiKey, + baseUrl: cfg.baseUrl || 'https://api.minimax.io', + }); +}); + +app.post('/api/config', (req, res) => { + const { apiKey, baseUrl } = req.body; + if (typeof apiKey !== 'string' || typeof baseUrl !== 'string') { + return res.status(400).json({ error: 'Invalid fields' }); + } + const cfg = { apiKey: apiKey.trim(), baseUrl: baseUrl.trim() || 'https://api.minimax.io' }; + saveConfig(cfg); + res.json({ ok: true }); +}); + +// --- Image generation --- + +app.post('/api/generate', async (req, res) => { + const { prompt, aspect_ratio, model } = req.body; + + if (!prompt || typeof prompt !== 'string' || !prompt.trim()) { + return res.status(400).json({ error: 'prompt is required' }); + } + + const cfg = loadConfig(); + if (!cfg.apiKey) { + return res.status(400).json({ error: 'API key not configured. Please set your MiniMax API key in settings.' }); + } + + const baseUrl = cfg.baseUrl || 'https://api.minimax.io'; + const endpoint = `${baseUrl}/v1/image_generation`; + + const payload = { + model: model || 'image-01', + prompt: prompt.trim(), + aspect_ratio: aspect_ratio || '1:1', + response_format: 'base64', + }; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${cfg.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + const msg = data.error?.message || data.error || `HTTP ${response.status}`; + return res.status(response.status).json({ error: msg }); + } + + // Return base64 images + const images = data.data?.image_base64 || []; + res.json({ images }); + } catch (err) { + console.error('[generate] error:', err.message); + res.status(500).json({ error: 'Failed to reach MiniMax API: ' + err.message }); + } +}); + +// --- Serve index.html at root --- +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'index.html')); +}); + +app.listen(PORT, HOST, () => { + console.log(`Image Generator running at http://${HOST === '0.0.0.0' ? '10.0.10.110' : HOST}:${PORT}`); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..bfc2a85 --- /dev/null +++ b/index.html @@ -0,0 +1,370 @@ + + + + + + Image Generator + + + + +
+ + +
+ + +
+ + + + + +
+
+
+ + +
+ +
+
+ + +
+ + +
+
+ + + + + + +
+ +
+ + + + + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..461e32e --- /dev/null +++ b/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +export NODE_PATH=$(npm root -g) +cd "$(dirname "$0")" +exec node app.js "$@" diff --git a/style.css b/style.css new file mode 100644 index 0000000..7737d24 --- /dev/null +++ b/style.css @@ -0,0 +1,518 @@ +/* ============================================================ + Reset & Base + ============================================================ */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0f0f13; + --surface: #18181f; + --surface2: #1f1f28; + --border: rgba(255,255,255,0.07); + --accent: #7c6af5; + --accent2: #a89bfa; + --text: #e4e4ea; + --text2: #8a8a9a; + --text3: #55555f; + --danger: #f56c6c; + --radius: 12px; + --radius2: 8px; + --ease: cubic-bezier(0.16, 1, 0.3, 1); +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button { cursor: pointer; font-family: inherit; } +a { color: var(--accent2); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ============================================================ + Layout + ============================================================ */ + +#app { + max-width: 640px; + margin: 0 auto; + padding: 0 20px 60px; + min-height: 100vh; +} + +/* ============================================================ + Header + ============================================================ */ + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 22px 0 28px; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.3px; +} + +.logo svg { + color: var(--accent2); + flex-shrink: 0; +} + +/* ============================================================ + Alert banner + ============================================================ */ + +.alert-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: rgba(124, 106, 245, 0.1); + border: 1px solid rgba(124, 106, 245, 0.25); + border-radius: var(--radius2); + color: var(--text2); + font-size: 13px; + margin-bottom: 20px; + animation: fadeSlideIn 0.4s var(--ease); +} + +.alert-banner svg { color: var(--accent2); flex-shrink: 0; } + +.link-btn { + background: none; + border: none; + color: var(--accent2); + font-size: inherit; + padding: 0; + text-decoration: underline; + cursor: pointer; +} + +/* ============================================================ + Form + ============================================================ */ + +.prompt-row { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + transition: border-color 0.2s; +} + +.prompt-row:focus-within { + border-color: rgba(124, 106, 245, 0.4); +} + +textarea { + width: 100%; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-family: inherit; + font-size: 15px; + line-height: 1.6; + padding: 14px 16px 10px; + resize: none; + border-radius: var(--radius); +} + +textarea::placeholder { color: var(--text3); } + +.prompt-footer { + display: flex; + justify-content: flex-end; + padding: 0 14px 10px; +} + +.char-count { + font-size: 11px; + color: var(--text3); + font-variant-numeric: tabular-nums; +} + +/* ============================================================ + Controls row + ============================================================ */ + +.controls { + display: flex; + align-items: flex-end; + gap: 12px; + margin-top: 14px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.control-group label { + font-size: 12px; + color: var(--text2); + font-weight: 500; + padding-left: 2px; +} + +select { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius2); + color: var(--text); + font-family: inherit; + font-size: 14px; + padding: 9px 12px; + outline: none; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a8a9a' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 30px; + transition: border-color 0.2s; +} + +select:focus { border-color: rgba(124, 106, 245, 0.4); } + +/* ============================================================ + Generate button + ============================================================ */ + +.generate-btn { + display: flex; + align-items: center; + gap: 8px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius2); + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + height: 42px; + transition: background 0.2s, transform 0.15s var(--ease), opacity 0.2s; + position: relative; + overflow: hidden; + flex-shrink: 0; +} + +.generate-btn:hover:not(:disabled) { + background: var(--accent2); + transform: translateY(-1px); +} + +.generate-btn:active:not(:disabled) { + transform: translateY(0); +} + +.generate-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.generate-btn .spinner { + display: none; + animation: spin 0.9s linear infinite; +} + +.generate-btn.loading .btn-icon, +.generate-btn.loading .btn-text { + display: none; +} + +.generate-btn.loading .spinner { + display: inline; +} + +/* ============================================================ + Error + ============================================================ */ + +.error-msg { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + padding: 10px 14px; + background: rgba(245, 108, 108, 0.1); + border: 1px solid rgba(245, 108, 108, 0.2); + border-radius: var(--radius2); + color: var(--danger); + font-size: 13px; + animation: fadeSlideIn 0.3s var(--ease); +} + +/* ============================================================ + Result area + ============================================================ */ + +.result-area { + margin-top: 28px; + animation: fadeSlideIn 0.5s var(--ease); +} + +.result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +#resultLabel { + font-size: 12px; + font-weight: 600; + color: var(--text2); + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.result-actions { + display: flex; + gap: 8px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius2); + color: var(--text2); + font-size: 12px; + padding: 6px 12px; + transition: background 0.2s, color 0.2s, border-color 0.2s; +} + +.action-btn:hover { + background: var(--surface2); + color: var(--text); + border-color: rgba(255,255,255,0.12); +} + +.image-wrap { + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border); +} + +.image-wrap img { + display: block; + width: 100%; + height: auto; +} + +@keyframes popIn { + 0% { opacity: 0; transform: scale(0.96); } + 100% { opacity: 1; transform: scale(1); } +} + +.pop-in { + animation: popIn 0.4s var(--ease); +} + +/* ============================================================ + Icon button + ============================================================ */ + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--text2); + border-radius: var(--radius2); + width: 36px; + height: 36px; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +.icon-btn:hover { + background: var(--surface); + color: var(--text); +} + +/* ============================================================ + Modal + ============================================================ */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; + z-index: 100; +} + +.modal-overlay.open { + opacity: 1; + pointer-events: all; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + width: 100%; + max-width: 420px; + transform: translateY(12px) scale(0.97); + transition: transform 0.3s var(--ease); +} + +.modal-overlay.open .modal { + transform: translateY(0) scale(1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 20px 0; +} + +.modal-header h2 { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.modal-body { + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.field { + display: flex; + flex-direction: column; + gap: 7px; +} + +.field label { + font-size: 13px; + font-weight: 500; + color: var(--text2); +} + +.input-row { + display: flex; + gap: 8px; +} + +.input-row input { + flex: 1; +} + +input[type="text"], +input[type="password"] { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius2); + color: var(--text); + font-family: inherit; + font-size: 14px; + padding: 9px 12px; + outline: none; + width: 100%; + transition: border-color 0.2s; +} + +input:focus { border-color: rgba(124, 106, 245, 0.4); } + +.field-hint { + font-size: 11px; + color: var(--text3); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 0 20px 18px; +} + +.btn-secondary { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius2); + color: var(--text2); + font-family: inherit; + font-size: 14px; + padding: 8px 18px; + transition: background 0.15s, color 0.15s; +} + +.btn-secondary:hover { + background: rgba(255,255,255,0.06); + color: var(--text); +} + +.btn-primary { + background: var(--accent); + border: none; + border-radius: var(--radius2); + color: #fff; + font-family: inherit; + font-size: 14px; + font-weight: 600; + padding: 8px 18px; + transition: background 0.15s; +} + +.btn-primary:hover { background: var(--accent2); } + +/* ============================================================ + Animations + ============================================================ */ + +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================================ + Responsive + ============================================================ */ + +@media (max-width: 480px) { + .controls { flex-direction: column; } + .control-group { width: 100%; } + .generate-btn { width: 100%; justify-content: center; } + .result-actions { gap: 6px; } + .action-btn { padding: 6px 10px; } +}