代码提交

This commit is contained in:
2026-03-03 10:38:37 +08:00
parent e904d7af1d
commit 000d1ef1a8
22 changed files with 7043 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
release/
out/
# Logs
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Electron
*.asar
EOF
node_modules

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
# 打包前让 pnpm 扁平化 node_modules避免 asar 里缺嵌套依赖
shamefully-hoist=true

BIN
01/004.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

14
config_nano_banana.json Normal file
View File

@@ -0,0 +1,14 @@
{
"api_key": "i0jpgqNGp1h92yKDAkBo3G6N79",
"poll_interval_seconds": 10,
"prompt": "扩展参考图片内容使其尺寸为4K比例为16:9清晰度为高清细节丰富清晰对焦高对比度专业摄影",
"size": "4K",
"aspect-ratio": "16:9",
"reference_urls": [],
"r2_account_id": "11d255a38a61526815d6c95a713bff12",
"r2_access_key_id": "f5d1db252f937b93fe84ba92329f5a7b",
"r2_secret_access_key": "b57b47be255263885ca37d0ec768d78ed1d07f3bf16d269e464e1d19166df63c",
"r2_bucket": "703",
"r2_public_url": "https://pub-cbc7501389e744aabf4dd3814483a61c.r2.dev"
}

241
core/generator.js Normal file
View File

@@ -0,0 +1,241 @@
const fs = require("fs");
const path = require("path");
const axios = require("axios");
const { S3Client, PutObjectCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
const CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".bmp": "image/bmp",
".gif": "image/gif",
};
function isR2Configured(config) {
return (
config &&
config.r2_account_id &&
config.r2_access_key_id &&
config.r2_secret_access_key &&
config.r2_bucket &&
config.r2_public_url
);
}
function createR2Client(config) {
const endpoint = `https://${config.r2_account_id}.r2.cloudflarestorage.com`;
return new S3Client({
region: "auto",
endpoint,
forcePathStyle: true,
credentials: {
accessKeyId: config.r2_access_key_id,
secretAccessKey: config.r2_secret_access_key,
},
});
}
/** 将指定本地文件路径上传到 R2返回 { urls, keys } */
async function uploadRefFilesToR2({ client, bucket, publicBaseUrl, filePaths }) {
const runId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
const prefix = `ref/${runId}`;
const urls = [];
const keys = [];
const base = (publicBaseUrl || "").replace(/\/$/, "");
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
if (!fs.existsSync(filePath)) continue;
const ext = path.extname(filePath).toLowerCase();
if (!IMAGE_EXTS.has(ext)) continue;
const name = path.basename(filePath);
const key = `${prefix}/${name}`;
const body = fs.readFileSync(filePath);
const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
})
);
urls.push(`${base}/${key}`);
keys.push(key);
}
return { urls, keys };
}
async function deleteR2Objects({ client, bucket, keys }) {
if (!keys || keys.length === 0) return;
await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: keys.map((Key) => ({ Key })) },
})
);
}
function extractImageUrls(dataObj) {
if (!dataObj) return [];
const candidates = [];
if (Array.isArray(dataObj)) {
dataObj.forEach((v) => v && candidates.push(String(v)));
} else if (typeof dataObj === "object") {
for (const key of ["img_url", "img_urls", "image_urls", "urls", "images", "result"]) {
const val = dataObj[key];
if (!val) continue;
if (typeof val === "string") candidates.push(val);
else if (Array.isArray(val)) val.forEach((v) => v && candidates.push(String(v)));
}
}
const seen = new Set();
return candidates.filter((u) => !seen.has(u) && seen.add(u));
}
/**
* 执行一次生图流程,支持进度回调
* @param {object} config - 完整配置api_key, r2_*, api_create_url, api_result_url, poll_interval_seconds, max_wait_seconds 等)
* @param {object} options - { prompt, size, aspectRatio, refFilePaths [], saveDir, extraParams {} }
* @param {function} onProgress - (step, message) => void
*/
async function runGeneration(config, options, onProgress = () => {}) {
const apiKey = config.api_key || process.env.WUYIN_API_KEY;
if (!apiKey) throw new Error("未配置 API 密钥");
const prompt = (options.prompt || "").trim();
if (!prompt) throw new Error("请输入提示词");
const createUrl = config.api_create_url || "https://api.wuyinkeji.com/api/async/image_nanoBanana2";
const resultUrl = config.api_result_url || "https://api.wuyinkeji.com/api/async/detail";
const pollIntervalMs = (config.poll_interval_seconds || 5) * 1000;
const maxWaitMs = (config.max_wait_seconds || 300) * 1000;
const saveDir = options.saveDir || config.default_save_dir || path.join(process.cwd(), "save");
const extraParams = options.extraParams || config.extra_params || {};
let r2KeysToDelete = [];
let combinedUrls = [];
if (isR2Configured(config) && options.refFilePaths && options.refFilePaths.length > 0) {
onProgress("upload", "正在上传参考图到 R2…");
const client = createR2Client(config);
const { urls, keys } = await uploadRefFilesToR2({
client,
bucket: config.r2_bucket,
publicBaseUrl: config.r2_public_url,
filePaths: options.refFilePaths,
});
r2KeysToDelete = keys;
combinedUrls = urls;
onProgress("upload_done", `已上传 ${urls.length} 张参考图`);
}
try {
onProgress("create", "正在创建生图任务…");
const body = {
prompt,
size: options.size || "1K",
aspectRatio: options.aspectRatio || "auto",
key: apiKey,
...extraParams,
};
if (combinedUrls.length > 0) body.urls = combinedUrls;
const createResp = await axios.post(createUrl, body, {
headers: {
Authorization: apiKey,
"Content-Type": "application/json;charset=utf-8;",
},
timeout: 30000,
});
const payload = createResp.data;
if (!payload || payload.code !== 200) {
throw new Error("创建任务失败: " + (payload?.msg || JSON.stringify(payload)));
}
const taskId = (payload.data || {}).id;
if (!taskId) throw new Error("返回数据中缺少任务 id");
onProgress("poll", "正在等待生成结果…");
const imageUrls = await pollResult({
apiKey,
taskId,
resultUrl,
pollIntervalMs,
maxWaitMs,
});
if (!imageUrls || imageUrls.length === 0) {
throw new Error("任务完成但未获取到图片 URL");
}
onProgress("download", `正在保存 ${imageUrls.length} 张图片…`);
if (!fs.existsSync(saveDir)) fs.mkdirSync(saveDir, { recursive: true });
const savedPaths = [];
for (let i = 0; i < imageUrls.length; i++) {
const url = imageUrls[i];
const resp = await axios.get(url, { responseType: "arraybuffer", timeout: 60000 });
let ext = ".jpg";
for (const e of [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]) {
if (url.toLowerCase().includes(e)) {
ext = e;
break;
}
}
const filePath = path.join(saveDir, `${taskId}_${i + 1}${ext}`);
fs.writeFileSync(filePath, resp.data);
savedPaths.push(filePath);
}
onProgress("done", `已保存到 ${saveDir}`);
return { taskId, saveDir, count: imageUrls.length, savedPaths };
} finally {
if (r2KeysToDelete.length > 0 && isR2Configured(config)) {
try {
const client = createR2Client(config);
await deleteR2Objects({ client, bucket: config.r2_bucket, keys: r2KeysToDelete });
onProgress("cleanup", "已删除 R2 临时参考图");
} catch (e) {
onProgress("cleanup_error", "删除 R2 参考图失败: " + e.message);
}
}
}
}
async function pollResult({ apiKey, taskId, resultUrl, pollIntervalMs, maxWaitMs }) {
const start = Date.now();
const headers = {
Authorization: apiKey,
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
};
while (true) {
const resp = await axios.get(resultUrl, {
params: { key: apiKey, id: taskId },
headers,
timeout: 30000,
});
const payload = resp.data;
if (!payload || payload.code !== 200) {
throw new Error(`查询失败: ${payload?.msg || "未知错误"}`);
}
const data = payload.data || {};
const status = data.status;
if (status === 2) return extractImageUrls(data);
if (status === 3) throw new Error("任务生成失败: " + (data.message || payload.msg || "未知原因"));
const urls = extractImageUrls(data);
if (urls.length > 0) return urls;
if (Date.now() - start > maxWaitMs) {
throw new Error("等待结果超时");
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
}
module.exports = {
runGeneration,
isR2Configured,
uploadRefFilesToR2,
deleteR2Objects,
createR2Client,
};

166
electron/main.js Normal file
View File

@@ -0,0 +1,166 @@
const path = require("path");
const fs = require("fs");
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
const { runGeneration } = require("../core/generator");
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const configPath = path.join(app.getPath("userData"), "config_nano_banana.json");
const defaultConfig = {
api_key: "",
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
poll_interval_seconds: 10,
max_wait_seconds: 300,
r2_account_id: "",
r2_access_key_id: "",
r2_secret_access_key: "",
r2_bucket: "",
r2_public_url: "",
default_save_dir: "",
extra_params: [],
};
function loadConfig() {
try {
const raw = fs.readFileSync(configPath, "utf8");
const data = JSON.parse(raw);
return { ...defaultConfig, ...data };
} catch {
const fallback = path.join(app.getAppPath(), "config_nano_banana.json");
try {
if (fs.existsSync(fallback)) {
const raw = fs.readFileSync(fallback, "utf8");
const data = JSON.parse(raw);
return { ...defaultConfig, ...data };
}
} catch (_) {}
return { ...defaultConfig };
}
}
function saveConfig(config) {
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
}
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 680,
minWidth: 640,
minHeight: 480,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
title: "NanoBanana 生图",
show: false,
});
win.once("ready-to-show", () => win.show());
if (isDev) {
win.loadURL("http://localhost:5173");
win.webContents.openDevTools();
} else {
win.loadFile(path.join(__dirname, "../dist/index.html"));
}
}
app.whenReady().then(() => {
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
ipcMain.handle("getConfig", () => loadConfig());
ipcMain.handle("saveConfig", (_e, config) => {
const current = loadConfig();
const merged = { ...current, ...config };
saveConfig(merged);
return loadConfig();
});
ipcMain.handle("selectDirectory", async (_e, defaultPath) => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openDirectory"],
defaultPath: defaultPath || undefined,
});
if (canceled || !filePaths.length) return null;
return filePaths[0];
});
ipcMain.handle("selectReferenceFiles", async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openFile", "multiSelections"],
filters: [
{ name: "图片", extensions: ["jpg", "jpeg", "png", "webp", "bmp", "gif"] },
],
});
if (canceled || !filePaths.length) return [];
return filePaths;
});
ipcMain.handle("getImageDataUrl", async (_e, filePath) => {
if (!filePath || typeof filePath !== "string") return null;
try {
const ext = filePath.replace(/^.*\./, "").toLowerCase();
const mime = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", bmp: "image/bmp", gif: "image/gif" }[ext] || "image/jpeg";
const buf = fs.readFileSync(filePath);
return `data:${mime};base64,${buf.toString("base64")}`;
} catch {
return null;
}
});
ipcMain.handle(
"startGeneration",
async (
_e,
{
prompt,
size,
aspectRatio,
refFilePaths,
saveDir,
extraParams,
}
) => {
const config = loadConfig();
const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
const send = (step, message) => {
if (win && !win.isDestroyed()) win.webContents.send("generationProgress", { step, message });
};
const extra = Array.isArray(config.extra_params)
? config.extra_params.reduce((acc, { key, value }) => {
if (key != null && key !== "") acc[key] = value;
return acc;
}, {})
: {};
const mergedExtra = { ...extra, ...(extraParams || {}) };
try {
const result = await runGeneration(
config,
{
prompt,
size: size || config.size || "1K",
aspectRatio: aspectRatio || config.aspectRatio || "auto",
refFilePaths: refFilePaths || [],
saveDir: saveDir || config.default_save_dir,
extraParams: Object.keys(mergedExtra).length ? mergedExtra : undefined,
},
send
);
return { success: true, ...result };
} catch (err) {
return { success: false, error: err.message || String(err) };
}
}
);

15
electron/preload.js Normal file
View File

@@ -0,0 +1,15 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getConfig: () => ipcRenderer.invoke("getConfig"),
saveConfig: (config) => ipcRenderer.invoke("saveConfig", config),
selectDirectory: (defaultPath) => ipcRenderer.invoke("selectDirectory", defaultPath),
selectReferenceFiles: () => ipcRenderer.invoke("selectReferenceFiles"),
getImageDataUrl: (filePath) => ipcRenderer.invoke("getImageDataUrl", filePath),
startGeneration: (options) => ipcRenderer.invoke("startGeneration", options),
onGenerationProgress: (cb) => {
const handler = (_e, data) => cb(data);
ipcRenderer.on("generationProgress", handler);
return () => ipcRenderer.removeListener("generationProgress", handler);
},
});

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NanoBanana 生图</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

389
nano_banana_client.js Normal file
View File

@@ -0,0 +1,389 @@
const fs = require("fs");
const path = require("path");
const axios = require("axios");
const { S3Client, PutObjectCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
// NanoBanana2 生图接口,文档 https://api.wuyinkeji.com/doc/65
const NANOBANANA_URL = "https://api.wuyinkeji.com/api/async/image_nanoBanana2";
// 全模型通用结果详情接口,文档见 https://api.wuyinkeji.com/doc/47
const RESULT_DETAIL_URL = "https://api.wuyinkeji.com/api/async/detail";
const CONFIG_FILE_NAME = "config_nano_banana.json";
function loadConfig(baseDir) {
const configPath = path.join(baseDir, CONFIG_FILE_NAME);
if (!fs.existsSync(configPath)) return {};
try {
const raw = fs.readFileSync(configPath, "utf8");
return JSON.parse(raw) || {};
} catch (e) {
console.log("读取配置文件失败,将忽略配置文件。错误:", e.message);
return {};
}
}
function ensureApiKey(config) {
const key = config.api_key || process.env.WUYIN_API_KEY;
if (!key) {
throw new Error(
"未找到 API 密钥,请在 config_nano_banana.json 中填写 api_key" +
"或设置环境变量 WUYIN_API_KEY。"
);
}
return key;
}
function ensurePrompt(config) {
const prompt = config.prompt;
if (!prompt || !prompt.trim()) {
throw new Error(
"未在 config_nano_banana.json 中找到有效的 prompt请先在文件中填写默认提示词。"
);
}
return prompt.trim();
}
/** 检查是否配置了 R2 图床(用于 01 参考图上传) */
function isR2Configured(config) {
return (
config &&
config.r2_account_id &&
config.r2_access_key_id &&
config.r2_secret_access_key &&
config.r2_bucket &&
config.r2_public_url
);
}
/** 创建 R2S3 兼容)客户端 */
function createR2Client(config) {
const accountId = config.r2_account_id;
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
return new S3Client({
region: "auto",
endpoint,
// 对不少 S3 兼容服务更稳:强制使用 path-style
// 形如 https://<accountId>.r2.cloudflarestorage.com/<bucket>/<key>
forcePathStyle: true,
credentials: {
accessKeyId: config.r2_access_key_id,
secretAccessKey: config.r2_secret_access_key,
},
});
}
/**
* 将 01 目录中的参考图上传到 R2返回可公网访问的 URL 列表及本次上传的 key 列表(用于事后删除)
*/
async function uploadRefImagesToR2({ client, bucket, publicBaseUrl, inputDir, maxFiles = 14 }) {
if (!fs.existsSync(inputDir)) return { urls: [], keys: [] };
const exts = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
const files = fs
.readdirSync(inputDir)
.map((name) => path.join(inputDir, name))
.filter((p) => exts.has(path.extname(p).toLowerCase()))
.slice(0, maxFiles);
const runId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
const prefix = `ref/${runId}`;
const urls = [];
const keys = [];
for (const filePath of files) {
const name = path.basename(filePath);
const key = `${prefix}/${name}`;
const body = fs.readFileSync(filePath);
const ext = path.extname(name).toLowerCase();
const contentType = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".bmp": "image/bmp", ".gif": "image/gif" }[ext] || "application/octet-stream";
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: contentType,
})
);
const base = publicBaseUrl.replace(/\/$/, "");
urls.push(`${base}/${key}`);
keys.push(key);
}
return { urls, keys };
}
/** 从 R2 删除指定 key 的对象 */
async function deleteR2Objects({ client, bucket, keys }) {
if (!keys || keys.length === 0) return;
await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: keys.map((Key) => ({ Key })) },
})
);
}
async function collectReferenceImages(inputDir, maxFiles = 14) {
if (!fs.existsSync(inputDir)) return [];
const exts = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
const files = fs
.readdirSync(inputDir)
.map((name) => path.join(inputDir, name))
.filter((p) => exts.has(path.extname(p).toLowerCase()))
.slice(0, maxFiles);
const result = [];
for (const file of files) {
const buf = fs.readFileSync(file);
result.push(buf.toString("base64"));
}
return result;
}
async function createTask({ apiKey, prompt, size, aspectRatio, refB64List }) {
const headers = {
Authorization: apiKey,
"Content-Type": "application/json;charset=utf-8;",
};
const body = {
prompt,
size,
aspectRatio,
key: apiKey,
};
if (refB64List && refB64List.length > 0) {
body.urls = refB64List;
}
const resp = await axios.post(NANOBANANA_URL, body, {
headers,
timeout: 30000,
});
const payload = resp.data;
if (!payload || payload.code !== 200) {
throw new Error("创建任务失败: " + JSON.stringify(payload));
}
const data = payload.data || {};
const taskId = data.id;
if (!taskId) {
throw new Error("返回数据中缺少任务 id: " + JSON.stringify(payload));
}
return taskId;
}
function extractImageUrls(dataObj) {
if (!dataObj) return [];
const candidates = [];
if (Array.isArray(dataObj)) {
for (const v of dataObj) {
if (v) candidates.push(String(v));
}
} else if (typeof dataObj === "object") {
// 兼容多种字段命名,包括结果详情返回的 result 数组
for (const key of ["img_url", "img_urls", "image_urls", "urls", "images", "result"]) {
if (dataObj[key]) {
const val = dataObj[key];
if (typeof val === "string") {
candidates.push(val);
} else if (Array.isArray(val)) {
for (const v of val) {
if (v) candidates.push(String(v));
}
}
}
}
}
const seen = new Set();
const result = [];
for (const url of candidates) {
if (!seen.has(url)) {
seen.add(url);
result.push(url);
}
}
return result;
}
async function queryResult({ apiKey, taskId, pollIntervalMs, maxWaitMs }) {
const headers = {
Authorization: apiKey,
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
};
const params = { key: apiKey, id: taskId };
const start = Date.now();
while (true) {
const resp = await axios.get(RESULT_DETAIL_URL, {
params,
headers,
timeout: 30000,
});
const payload = resp.data;
if (!payload || payload.code !== 200) {
const msg = (payload && payload.msg) || "未知错误";
throw new Error(`查询任务失败: code=${payload && payload.code}, msg=${msg}`);
}
const data = payload.data || {};
const status = data.status;
if (status === 2) {
const urls = extractImageUrls(data);
return urls;
}
if (status === 3) {
const reason = data.message || payload.msg || "未知原因";
throw new Error("任务生成失败: " + reason);
}
const urls = extractImageUrls(data);
if (urls.length > 0) return urls;
if (Date.now() - start > maxWaitMs) {
throw new Error("等待任务结果超时,请稍后在控制台或结果查询接口自行确认。");
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
async function downloadImages({ urls, outputDir, taskId }) {
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 60000,
});
let ext = ".jpg";
for (const cand of [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]) {
if (url.toLowerCase().includes(cand)) {
ext = cand;
break;
}
}
const filename = path.join(outputDir, `${taskId}_${i + 1}${ext}`);
fs.writeFileSync(filename, resp.data);
console.log("已保存:", filename);
} catch (e) {
console.log("下载失败:", url, "| 错误:", e.message);
}
}
}
async function main() {
const baseDir = __dirname;
const config = loadConfig(baseDir);
const apiKey = ensureApiKey(config);
const prompt = ensurePrompt(config);
const size = config.size || "1K";
const aspectRatio = config.aspectRatio || "auto";
const pollIntervalMs = (config.poll_interval_seconds || 5) * 1000;
const maxWaitMs = (config.max_wait_seconds || 300) * 1000;
const inputDir = path.join(baseDir, config.input_dir || "01");
const outputDir = path.join(baseDir, config.output_dir || "save");
let combinedUrls = [];
let r2KeysToDelete = [];
const useR2 = isR2Configured(config);
if (useR2) {
// 使用 R2 图床:将 01 目录的图片上传到 R2用返回的 URL 作为参考图
const client = createR2Client(config);
const { urls: r2Urls, keys } = await uploadRefImagesToR2({
client,
bucket: config.r2_bucket,
publicBaseUrl: config.r2_public_url,
inputDir,
maxFiles: 14,
});
r2KeysToDelete = keys;
if (r2Urls.length > 0) {
console.log(`已上传 ${r2Urls.length} 张参考图到 R2 图床。`);
}
combinedUrls = [...r2Urls];
} else {
// 未配置 R2本地 01 转为 base64
const refB64List = await collectReferenceImages(inputDir);
if (refB64List.length > 0) {
console.log(`已从目录 ${inputDir} 读取 ${refB64List.length} 张本地参考图base64`);
}
combinedUrls = [...refB64List];
}
// 配置中的参考图 URL直接透传给接口
let extraUrls = [];
if (typeof config.reference_urls === "string") {
extraUrls = [config.reference_urls];
} else if (Array.isArray(config.reference_urls)) {
extraUrls = config.reference_urls.filter((u) => !!u).map(String);
}
combinedUrls = [...combinedUrls, ...extraUrls];
if (extraUrls.length > 0) {
console.log(`已从配置文件读取 ${extraUrls.length} 个参考图 URL。`);
}
if (combinedUrls.length === 0) {
console.log(`未找到任何参考图,将仅根据提示词生成。`);
}
try {
console.log("正在创建 NanoBanana2 任务...");
const taskId = await createTask({
apiKey,
prompt,
size,
aspectRatio,
refB64List: combinedUrls,
});
console.log("任务已创建,任务 id:", taskId);
console.log("开始轮询任务结果...");
const urls = await queryResult({
apiKey,
taskId,
pollIntervalMs,
maxWaitMs,
});
if (!urls || urls.length === 0) {
console.log("任务完成,但未在结果中找到图片 URL请登录速创API控制台或检查结果详情接口。");
return;
}
console.log(`任务完成,共获取到 ${urls.length} 个图片 URL开始下载...`);
await downloadImages({ urls, outputDir, taskId });
console.log("全部处理完成。");
} finally {
if (useR2 && r2KeysToDelete.length > 0) {
try {
const client = createR2Client(config);
await deleteR2Objects({ client, bucket: config.r2_bucket, keys: r2KeysToDelete });
console.log("已删除本次上传的 R2 参考图。");
} catch (e) {
console.warn("删除 R2 参考图失败:", e.message);
}
}
}
}
main().catch((err) => {
console.error("执行出错:", err.message || err);
process.exit(1);
});

343
nano_banana_client.py Normal file
View File

@@ -0,0 +1,343 @@
import argparse
import base64
import json
import os
import time
from pathlib import Path
from typing import List, Optional
import requests
# NanoBanana2 生图接口,文档 https://api.wuyinkeji.com/doc/65
NANOBANANA_URL = "https://api.wuyinkeji.com/api/async/image_nanoBanana2"
# 全模型通用结果详情接口,文档见 https://api.wuyinkeji.com/doc/47
RESULT_DETAIL_URL = "https://api.wuyinkeji.com/api/async/detail"
CONFIG_FILE_NAME = "config_nano_banana.json"
def load_config(base_dir: Path) -> dict:
"""
从配置文件中读取默认参数(如 api_key、prompt 等)。
"""
config_path = base_dir / CONFIG_FILE_NAME
if not config_path.exists():
return {}
try:
with config_path.open("r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception as e:
print(f"读取配置文件失败,将忽略配置文件。错误: {e}")
return {}
def load_api_key(cli_key: Optional[str], config: dict) -> str:
"""
优先级:命令行参数 > 配置文件 > 环境变量 WUYIN_API_KEY。
"""
key_from_config = config.get("api_key") if isinstance(config, dict) else None
key = cli_key or key_from_config or os.environ.get("WUYIN_API_KEY")
if not key:
raise SystemExit(
"未找到 API 密钥,请在 config_nano_banana.json 中填写 api_key"
"或通过参数 --api-key 传入,或在环境变量 WUYIN_API_KEY 中设置。"
)
return key
def collect_reference_images(input_dir: Path, max_files: int = 14) -> List[str]:
"""
从指定目录读取参考图,返回 base64 字符串数组。
"""
if not input_dir.exists():
return []
exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
files = [p for p in sorted(input_dir.iterdir()) if p.suffix.lower() in exts]
files = files[:max_files]
encoded_list: List[str] = []
for p in files:
with p.open("rb") as f:
b64 = base64.b64encode(f.read()).decode("ascii")
encoded_list.append(b64)
return encoded_list
def create_task(
api_key: str,
prompt: str,
size: str,
aspect_ratio: str,
ref_b64_list: List[str],
) -> str:
"""
调用 NanoBanana2 异步图片生成接口,返回任务 id。
使用 JSON 请求体urls 字段为数组,避免类型错误。
"""
headers = {
"Authorization": api_key,
"Content-Type": "application/json;charset=utf-8;",
}
data: dict = {
"prompt": prompt,
"size": size,
"aspectRatio": aspect_ratio,
"key": api_key,
}
if ref_b64_list:
# urls 为字符串数组,元素可以是 URL 或 Base64
data["urls"] = ref_b64_list
resp = requests.post(NANOBANANA_URL, json=data, headers=headers, timeout=30)
resp.raise_for_status()
payload = resp.json()
if payload.get("code") != 200:
raise RuntimeError(f"创建任务失败: {payload}")
data_obj = payload.get("data") or {}
task_id = data_obj.get("id")
if not task_id:
raise RuntimeError(f"返回数据中缺少任务 id: {payload}")
return task_id
def extract_image_urls(data_obj) -> List[str]:
"""
尝试从结果详情 data 字段中提取图片 URL兼容多种字段命名。
"""
if not data_obj:
return []
# 常见字段名兼容
candidates = []
if isinstance(data_obj, dict):
# 兼容多种字段命名,包括结果详情返回的 result 数组
for key in ("img_url", "img_urls", "image_urls", "urls", "images", "result"):
if key in data_obj and data_obj[key]:
val = data_obj[key]
if isinstance(val, str):
candidates.append(val)
elif isinstance(val, list):
candidates.extend(str(v) for v in val if v)
elif isinstance(data_obj, list):
candidates.extend(str(v) for v in data_obj if v)
# 去重
seen = set()
result: List[str] = []
for u in candidates:
if u not in seen:
seen.add(u)
result.append(u)
return result
def query_result(
api_key: str,
task_id: str,
poll_interval: float = 5.0,
max_wait: float = 300.0,
) -> List[str]:
"""
轮询结果详情接口,直到任务完成或超时,返回图片 URL 列表。
"""
start = time.time()
params = {"key": api_key, "id": task_id}
headers = {
"Authorization": api_key,
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
}
while True:
resp = requests.get(RESULT_DETAIL_URL, params=params, headers=headers, timeout=30)
resp.raise_for_status()
payload = resp.json()
if payload.get("code") != 200:
# 有些平台在排队/处理中阶段也返回 code=200这里仅在明确失败时直接抛出
msg = payload.get("msg") or "未知错误"
raise RuntimeError(f"查询任务失败: code={payload.get('code')}, msg={msg}")
data_obj = payload.get("data") or {}
status = data_obj.get("status")
# 约定0 排队中1 生成中2 成功3 失败
if status == 2:
urls = extract_image_urls(data_obj)
if urls:
return urls
# 没有找到 URL 但状态为成功,直接返回空列表交由上层处理
return []
elif status == 3:
reason = data_obj.get("fail_reason") or payload.get("msg") or "未知原因"
raise RuntimeError(f"任务生成失败: {reason}")
# 如果未提供 status但已经带有图片 URL也视为成功
urls = extract_image_urls(data_obj)
if urls:
return urls
if time.time() - start > max_wait:
raise TimeoutError("等待任务结果超时,请稍后在控制台或结果查询接口自行确认。")
time.sleep(poll_interval)
def download_images(urls: List[str], output_dir: Path, task_id: str) -> None:
"""
将图片 URL 下载到指定目录。
"""
output_dir.mkdir(parents=True, exist_ok=True)
for idx, url in enumerate(urls, start=1):
try:
resp = requests.get(url, stream=True, timeout=60)
resp.raise_for_status()
except Exception as e:
print(f"下载失败: {url} | 错误: {e}")
continue
# 根据 URL 粗略推断扩展名
ext = ".jpg"
for cand in (".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"):
if cand in url.lower():
ext = cand
break
filename = output_dir / f"{task_id}_{idx}{ext}"
with filename.open("wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
print(f"已保存: {filename}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"NanoBanana2 调用脚本:"
"读取 ./01 目录中的参考图,生成图片并保存到 ./save 目录。"
)
)
parser.add_argument(
"-p",
"--prompt",
required=False,
help="提示词,可在此修改,也可留空在运行时输入。",
)
parser.add_argument(
"--size",
default="1K",
choices=["1K", "2K", "4K"],
help="输出图像大小,支持 1K/2K/4K默认 1K。",
)
parser.add_argument(
"--aspect-ratio",
default="auto",
help="输出图像比例,如 auto、1:1、16:9 等,默认 auto。",
)
parser.add_argument(
"--input-dir",
default="01",
help="参考图目录,相对于当前脚本所在目录,默认 01。",
)
parser.add_argument(
"--output-dir",
default="save",
help="生成图片保存目录,相对于当前脚本所在目录,默认 save。",
)
parser.add_argument(
"--api-key",
help="速创API接口密钥如未提供将尝试从环境变量 WUYIN_API_KEY 读取。",
)
parser.add_argument(
"--poll-interval",
type=float,
default=5.0,
help="轮询结果详情接口的间隔秒数,默认 5 秒。",
)
parser.add_argument(
"--max-wait",
type=float,
default=300.0,
help="等待结果的最长时间(秒),默认 300 秒。",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
base_dir = Path(__file__).resolve().parent
config = load_config(base_dir)
api_key = load_api_key(args.api_key, config)
# 提示词优先级:命令行参数 > 配置文件 > 运行时输入
prompt = args.prompt or (config.get("prompt") if isinstance(config, dict) else None)
if not prompt:
prompt = input("请输入提示词(prompt)").strip()
if not prompt:
raise SystemExit("提示词不能为空。")
input_dir = base_dir / args.input_dir
output_dir = base_dir / args.output_dir
# 本地参考图(转为 base64
ref_b64_list = collect_reference_images(input_dir)
# 配置中的参考图 URL直接透传给接口
extra_urls_cfg = config.get("reference_urls") if isinstance(config, dict) else None
extra_urls: List[str] = []
if isinstance(extra_urls_cfg, str):
extra_urls = [extra_urls_cfg]
elif isinstance(extra_urls_cfg, list):
extra_urls = [str(u) for u in extra_urls_cfg if u]
combined_urls: List[str] = []
combined_urls.extend(ref_b64_list)
combined_urls.extend(extra_urls)
if ref_b64_list:
print(f"已从目录 {input_dir} 读取 {len(ref_b64_list)} 张本地参考图。")
if extra_urls:
print(f"已从配置文件读取 {len(extra_urls)} 个参考图 URL。")
if not combined_urls:
print(f"未找到任何参考图,将仅根据提示词生成。")
print("正在创建 NanoBanana2 任务...")
task_id = create_task(
api_key=api_key,
prompt=prompt,
size=args.size,
aspect_ratio=args.aspect_ratio,
ref_b64_list=combined_urls,
)
print(f"任务已创建,任务 id: {task_id}")
print("开始轮询任务结果(结果详情接口地址可根据官方文档调整)...")
urls = query_result(
api_key=api_key,
task_id=task_id,
poll_interval=args.poll_interval,
max_wait=args.max_wait,
)
if not urls:
print("任务完成,但未在结果中找到图片 URL请登录速创API控制台或检查结果详情接口。")
return
print(f"任务完成,共获取到 {len(urls)} 个图片 URL开始下载...")
download_images(urls, output_dir=output_dir, task_id=task_id)
print("全部处理完成。")
if __name__ == "__main__":
main()

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "nano-banana-img",
"version": "1.0.0",
"description": "NanoBanana2 本地图片生成脚本与桌面应用",
"main": "electron/main.js",
"scripts": {
"dev": "node nano_banana_client.js",
"ui": "vite --open",
"electron": "electron .",
"app": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"build": "vite build",
"start": "electron .",
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ electron-builder --win",
"dist:clean": "rimraf release && npm run dist",
"deps:check": "node -e \"require('./core/generator.js'); console.log('主进程依赖完整');\""
},
"build": {
"appId": "com.nanobanana.img",
"productName": "NanoBanana生图",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"core/**/*",
"node_modules/**/*",
"package.json"
],
"win": {
"target": [
{ "target": "dir", "arch": ["x64"] }
],
"icon": null,
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerLanguages": ["zh_CN"],
"shortcutName": "NanoBanana生图"
}
},
"dependencies": {
"axios": "^1.7.0",
"form-data": "^4.0.0",
"combined-stream": "^1.0.8",
"delayed-stream": "^1.0.0",
"mime-types": "^2.1.35",
"mime-db": "^1.52.0",
"@aws-sdk/client-s3": "^3.700.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"cross-env": "^7.0.3",
"concurrently": "^8.2.0",
"wait-on": "^7.2.0",
"rimraf": "^5.0.5"
}
}

4627
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- electron
- esbuild

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
requests>=2.31.0

384
src/App.css Normal file
View File

@@ -0,0 +1,384 @@
.app {
max-width: 820px;
margin: 0 auto;
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
color: var(--text-muted);
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.app-header h1 {
margin: 0;
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.app-nav {
display: flex;
gap: 4px;
}
.app-nav button {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text);
font-size: 0.9rem;
}
.app-nav button:hover {
background: #222228;
border-color: var(--accent);
}
.app-nav button.active {
background: var(--accent);
border-color: var(--accent);
color: #0f0f12;
}
.app-main {
min-height: 400px;
}
.tab-pane {
display: none;
}
.tab-pane.tab-pane-active {
display: block;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.card h2 {
margin: 0 0 16px;
font-size: 1rem;
font-weight: 600;
color: var(--text-muted);
}
.form-row {
margin-bottom: 14px;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-row label {
display: block;
margin-bottom: 4px;
font-size: 0.8rem;
color: var(--text-muted);
}
.form-row input,
.form-row select,
.form-row textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
}
.form-row input:focus,
.form-row select:focus,
.form-row textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-row textarea {
min-height: 72px;
resize: vertical;
}
.form-row .hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 4px;
}
.btn {
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
}
.btn-primary {
background: var(--accent);
color: #0f0f12;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: #3a3a42;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
}
.log {
margin-top: 16px;
padding: 12px;
background: var(--bg);
border-radius: 8px;
font-family: ui-monospace, monospace;
font-size: 0.8rem;
color: var(--text-muted);
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log-entry {
margin-bottom: 4px;
}
.log-entry.done {
color: var(--success);
}
.log-entry.error {
color: var(--error);
}
.input-with-btn {
display: flex;
gap: 8px;
}
.input-with-btn input {
flex: 1;
}
.extra-param-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.extra-key {
width: 140px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 0.85rem;
}
.extra-value {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 0.85rem;
}
.form-hint {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0 0 12px;
}
.form-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.form-message {
font-size: 0.85rem;
color: var(--success);
}
.form-warn {
font-size: 0.8rem;
color: var(--error);
margin: 10px 0 0;
}
.form-row-inline {
display: flex;
gap: 16px;
}
.form-row-inline > div {
flex: 1;
}
.ref-files {
margin-bottom: 10px;
max-height: 160px;
overflow-y: auto;
}
.ref-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.ref-file-name {
flex: 1;
font-size: 0.85rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-success-card h2 {
color: var(--success);
}
.result-success-msg {
margin: 0 0 12px;
font-size: 0.9rem;
color: var(--text-muted);
}
.result-preview-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.result-preview-thumb {
width: 120px;
height: 120px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.result-preview-thumb:hover {
border-color: var(--accent);
}
.result-preview-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
cursor: pointer;
}
.preview-close {
position: absolute;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: var(--bg-card);
color: var(--text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
z-index: 1001;
}
.preview-close:hover {
background: var(--border);
}
.preview-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
cursor: default;
}
.ref-drop-zone {
min-height: 100px;
padding: 12px;
margin-bottom: 10px;
border: 2px dashed var(--border);
border-radius: 8px;
background: var(--bg);
transition: border-color 0.15s, background 0.15s;
}
.ref-drop-zone-active {
border-color: var(--accent);
background: rgba(167, 139, 250, 0.08);
}
.ref-drop-placeholder {
color: var(--text-muted);
font-size: 0.9rem;
text-align: center;
padding: 24px 12px;
}
.ref-drop-placeholder-inline {
padding: 12px;
font-size: 0.85rem;
}

110
src/App.jsx Normal file
View File

@@ -0,0 +1,110 @@
import { useState, useEffect, useCallback } from "react";
import Settings from "./Settings";
import Generate from "./Generate";
import "./App.css";
export default function App() {
const [tab, setTab] = useState("generate");
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [runningInGenerate, setRunningInGenerate] = useState(false);
const loadConfig = useCallback(async () => {
if (typeof window.electronAPI?.getConfig !== "function") {
setConfig(getFallbackConfig());
setLoading(false);
return;
}
try {
const c = await window.electronAPI.getConfig();
setConfig(normalizeConfig(c));
} catch {
setConfig(getFallbackConfig());
}
setLoading(false);
}, []);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const saveConfig = async (next) => {
if (typeof window.electronAPI?.saveConfig !== "function") return;
const c = await window.electronAPI.saveConfig(next);
setConfig(normalizeConfig(c));
};
if (loading) {
return (
<div className="app-loading">
<span>加载中</span>
</div>
);
}
return (
<div className="app">
<header className="app-header app-title">
<h1>NanoBanana 生图</h1>
<nav className="app-nav">
<button
className={tab === "generate" ? "active" : ""}
onClick={() => setTab("generate")}
>
生图{runningInGenerate ? " (进行中)" : ""}
</button>
<button
className={tab === "settings" ? "active" : ""}
onClick={() => setTab("settings")}
>
设置
</button>
</nav>
</header>
<main className="app-main">
<div className={`tab-pane ${tab === "settings" ? "tab-pane-active" : ""}`}>
<Settings config={config ?? getFallbackConfig()} onSave={saveConfig} />
</div>
<div className={`tab-pane ${tab === "generate" ? "tab-pane-active" : ""}`}>
<Generate
config={config ?? getFallbackConfig()}
onRunningChange={setRunningInGenerate}
/>
</div>
</main>
</div>
);
}
function getFallbackConfig() {
return {
api_key: "",
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
poll_interval_seconds: 10,
max_wait_seconds: 300,
r2_account_id: "",
r2_access_key_id: "",
r2_secret_access_key: "",
r2_bucket: "",
r2_public_url: "",
default_save_dir: "",
extra_params: [],
};
}
function normalizeConfig(c) {
if (!c) return getFallbackConfig();
const extra = Array.isArray(c.extra_params)
? c.extra_params
: [];
return {
...getFallbackConfig(),
...c,
extra_params: extra.map((e) =>
typeof e === "object" && e !== null
? { key: e.key ?? "", value: e.value ?? "" }
: { key: "", value: "" }
),
};
}

51
src/ErrorBoundary.jsx Normal file
View File

@@ -0,0 +1,51 @@
import React from "react";
export class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
console.error("ErrorBoundary:", error, info);
}
render() {
if (this.state.error) {
return (
<div style={{
padding: 24,
maxWidth: 480,
margin: "40px auto",
background: "var(--bg-card)",
border: "1px solid var(--border)",
borderRadius: 10,
color: "var(--text)",
fontFamily: "system-ui, sans-serif",
}}>
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem" }}>界面加载出错</h2>
<p style={{ margin: 0, fontSize: 0.9, color: "var(--text-muted)" }}>
{this.state.error?.message || String(this.state.error)}
</p>
<button
type="button"
onClick={() => this.setState({ error: null })}
style={{
marginTop: 16,
padding: "8px 16px",
background: "var(--accent)",
border: "none",
borderRadius: 8,
color: "#0f0f12",
cursor: "pointer",
}}
>
重试
</button>
</div>
);
}
return this.props.children;
}
}

332
src/Generate.jsx Normal file
View File

@@ -0,0 +1,332 @@
import { useState, useEffect, useCallback } from "react";
const SIZES = ["1K", "2K", "4K"];
const RATIOS = ["auto", "1:1", "16:9", "4:3", "3:4", "9:16"];
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
function getPathFromFile(file) {
if (file.path) return file.path;
return null;
}
function isImagePath(pathStr) {
if (!pathStr) return false;
const ext = pathStr.replace(/^.*\./, ".").toLowerCase();
return IMAGE_EXTS.has(ext);
}
export default function Generate({ config, onRunningChange }) {
const [prompt, setPrompt] = useState(config?.prompt || "");
const [size, setSize] = useState(config?.size || "1K");
const [aspectRatio, setAspectRatio] = useState(config?.aspectRatio || config?.["aspect-ratio"] || "auto");
const [refFiles, setRefFiles] = useState([]);
const [saveDir, setSaveDir] = useState(config?.default_save_dir || "");
const [running, setRunning] = useState(false);
const [log, setLog] = useState([]);
const [result, setResult] = useState(null);
const [dragOver, setDragOver] = useState(false);
const [previewUrls, setPreviewUrls] = useState([]);
const [previewIndex, setPreviewIndex] = useState(null);
useEffect(() => {
if (typeof onRunningChange === "function") onRunningChange(running);
}, [running, onRunningChange]);
useEffect(() => {
if (config?.prompt) setPrompt(config.prompt);
if (config?.size) setSize(config.size);
if (config?.aspectRatio) setAspectRatio(config.aspectRatio);
if (config?.["aspect-ratio"]) setAspectRatio(config["aspect-ratio"]);
if (config?.default_save_dir) setSaveDir(config.default_save_dir);
}, [config]);
useEffect(() => {
if (!result?.success || !result?.savedPaths?.length || typeof window.electronAPI?.getImageDataUrl !== "function") {
setPreviewUrls([]);
return;
}
let cancelled = false;
(async () => {
const urls = [];
for (const p of result.savedPaths) {
if (cancelled) return;
const dataUrl = await window.electronAPI.getImageDataUrl(p);
if (cancelled) return;
urls.push(dataUrl || "");
}
if (!cancelled) setPreviewUrls(urls);
})();
return () => { cancelled = true; };
}, [result?.taskId, result?.savedPaths?.length]);
const addPaths = useCallback((paths) => {
if (!paths || !paths.length) return;
const valid = paths.filter(isImagePath);
const unique = [...new Set(valid)];
setRefFiles((prev) => {
const set = new Set(prev);
unique.forEach((p) => set.add(p));
return [...set];
});
}, []);
const addRefFiles = useCallback(async () => {
if (typeof window.electronAPI?.selectReferenceFiles !== "function") return;
const paths = await window.electronAPI.selectReferenceFiles();
addPaths(paths);
}, [addPaths]);
const clearRefFiles = useCallback(() => {
setRefFiles([]);
}, []);
const pickSaveDir = useCallback(async () => {
if (typeof window.electronAPI?.selectDirectory !== "function") return;
const dir = await window.electronAPI.selectDirectory(saveDir || undefined);
if (dir) setSaveDir(dir);
}, [saveDir]);
const removeRefFile = useCallback((index) => {
setRefFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleDrop = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
if (running) return;
const items = e.dataTransfer?.files;
if (!items?.length) return;
const paths = [];
for (let i = 0; i < items.length; i++) {
const p = getPathFromFile(items[i]);
if (p) paths.push(p);
}
addPaths(paths);
setDragOver(false);
},
[running, addPaths]
);
const handleDragOver = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
if (running) return;
e.dataTransfer.dropEffect = "copy";
setDragOver(true);
}, [running]);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
}, []);
const start = useCallback(async () => {
if (running) return;
setRunning(true);
setLog([]);
setResult(null);
const unsub =
typeof window.electronAPI?.onGenerationProgress === "function"
? window.electronAPI.onGenerationProgress(({ step, message }) => {
setLog((prev) => [...prev, `[${step}] ${message}`]);
})
: () => {};
try {
const res = await window.electronAPI?.startGeneration?.({
prompt: prompt.trim(),
size,
aspectRatio,
refFilePaths: refFiles,
saveDir: saveDir || undefined,
});
if (res?.success) {
setResult(res);
setLog((prev) => [...prev, `完成:已保存 ${res.count} 张到 ${res.saveDir}`]);
} else {
setLog((prev) => [...prev, `错误: ${res?.error || "未知"}`]);
}
} catch (e) {
setLog((prev) => [...prev, "错误: " + (e.message || String(e))]);
} finally {
unsub?.();
setRunning(false);
}
}, [running, prompt, size, aspectRatio, refFiles, saveDir]);
const hasR2 = config?.r2_account_id && config?.r2_bucket && config?.r2_public_url;
const needR2ForRef = refFiles.length > 0;
return (
<div className="generate">
<div className="card">
<h2>生图参数</h2>
<div className="form-row">
<label>提示词</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="描述你想要的画面…"
disabled={running}
/>
</div>
<div className="form-row form-row-inline">
<div>
<label>尺寸</label>
<select value={size} onChange={(e) => setSize(e.target.value)} disabled={running}>
{SIZES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label>比例</label>
<select value={aspectRatio} onChange={(e) => setAspectRatio(e.target.value)} disabled={running}>
{RATIOS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
</div>
</div>
<div className="card">
<h2>参考图</h2>
<p className="form-hint">拖拽或选择图片将上传到 R2 作为参考需在设置中配置 R2</p>
<div
className={`ref-drop-zone ${dragOver ? "ref-drop-zone-active" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<div className="ref-files">
{refFiles.map((path, i) => (
<div key={i} className="ref-file-item">
<span className="ref-file-name" title={path || ""}>
{typeof path === "string" ? path.replace(/^.*[/\\]/, "") : ""}
</span>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => removeRefFile(i)}
disabled={running}
>
移除
</button>
</div>
))}
</div>
{refFiles.length === 0 && (
<div className="ref-drop-placeholder">
{dragOver ? "松开即可添加" : "拖拽图片到此处"}
</div>
)}
{refFiles.length > 0 && dragOver && (
<div className="ref-drop-placeholder ref-drop-placeholder-inline">
松开添加更多
</div>
)}
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={addRefFiles} disabled={running}>
添加参考图
</button>
{refFiles.length > 0 && (
<button type="button" className="btn btn-secondary" onClick={clearRefFiles} disabled={running}>
清空
</button>
)}
</div>
{needR2ForRef && !hasR2 && (
<p className="form-warn">已选参考图但未配置 R2请先在设置中填写 R2 信息</p>
)}
</div>
<div className="card">
<h2>保存位置</h2>
<div className="input-with-btn">
<input type="text" readOnly value={saveDir || ""} placeholder="未选择(将使用设置中的默认目录)" />
<button type="button" className="btn btn-secondary" onClick={pickSaveDir} disabled={running}>
选择文件夹
</button>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={start}
disabled={running || !prompt.trim() || (needR2ForRef && !hasR2)}
>
{running ? "生成中…" : "开始生图"}
</button>
</div>
{(log.length > 0 || result) && (
<div className="card">
<h2>运行日志</h2>
<div className="log">
{log.map((line, i) => (
<div
key={i}
className={`log-entry ${
line.startsWith("错误") ? "error" : line.startsWith("完成") || line.includes("已保存") ? "done" : ""
}`}
>
{line}
</div>
))}
</div>
</div>
)}
{result?.success && result?.savedPaths?.length > 0 && (
<div className="card result-success-card">
<h2>生成成功</h2>
<p className="result-success-msg">
共保存 {result.count} 张图片到 {result.saveDir}
</p>
<div className="result-preview-grid">
{previewUrls.map((dataUrl, i) => (
<button
key={i}
type="button"
className="result-preview-thumb"
onClick={() => setPreviewIndex(i)}
>
{dataUrl ? (
<img src={dataUrl} alt={`预览 ${i + 1}`} />
) : (
<span>加载中</span>
)}
</button>
))}
</div>
</div>
)}
{previewIndex !== null && previewUrls[previewIndex] && (
<div
className="preview-overlay"
onClick={() => setPreviewIndex(null)}
role="dialog"
aria-modal="true"
>
<button type="button" className="preview-close" onClick={() => setPreviewIndex(null)} aria-label="关闭">
×
</button>
<img
src={previewUrls[previewIndex]}
alt="预览"
className="preview-img"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</div>
);
}

189
src/Settings.jsx Normal file
View File

@@ -0,0 +1,189 @@
import { useState, useCallback } from "react";
const R2_FIELDS = [
{ key: "r2_account_id", label: "R2 Account ID" },
{ key: "r2_access_key_id", label: "R2 Access Key ID" },
{ key: "r2_secret_access_key", label: "R2 Secret Access Key", type: "password" },
{ key: "r2_bucket", label: "R2 Bucket 名称" },
{ key: "r2_public_url", label: "R2 公网访问地址" },
];
function getDefaultForm() {
return {
api_key: "",
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
r2_account_id: "",
r2_access_key_id: "",
r2_secret_access_key: "",
r2_bucket: "",
r2_public_url: "",
default_save_dir: "",
extra_params: [],
};
}
export default function Settings({ config, onSave }) {
const safeConfig = config && typeof config === "object" ? config : {};
const [form, setForm] = useState(() => ({ ...getDefaultForm(), ...safeConfig }));
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const update = useCallback((key, value) => {
setForm((prev) => ({ ...prev, [key]: value }));
}, []);
const updateExtra = useCallback((index, field, value) => {
setForm((prev) => {
const next = [...(prev.extra_params || [])];
if (!next[index]) next[index] = { key: "", value: "" };
next[index] = { ...next[index], [field]: value };
return { ...prev, extra_params: next };
});
}, []);
const addExtra = useCallback(() => {
setForm((prev) => ({
...prev,
extra_params: [...(prev.extra_params || []), { key: "", value: "" }],
}));
}, []);
const removeExtra = useCallback((index) => {
setForm((prev) => ({
...prev,
extra_params: (prev.extra_params || []).filter((_, i) => i !== index),
}));
}, []);
const pickDir = useCallback(async () => {
if (typeof window.electronAPI?.selectDirectory !== "function") return;
const dir = await window.electronAPI.selectDirectory(form.default_save_dir);
if (dir) update("default_save_dir", dir);
}, [form.default_save_dir, update]);
const handleSave = useCallback(async () => {
setSaving(true);
setMessage("");
try {
await onSave(form);
setMessage("已保存");
setTimeout(() => setMessage(""), 2000);
} catch (e) {
setMessage("保存失败: " + (e.message || String(e)));
}
setSaving(false);
}, [form, onSave]);
return (
<div className="settings">
<div className="card">
<h2>API 与接口</h2>
<div className="form-row">
<label>API Key</label>
<input
type="password"
value={form.api_key || ""}
onChange={(e) => update("api_key", e.target.value)}
placeholder="速创 API 密钥"
/>
</div>
<div className="form-row">
<label>创建任务接口 URL</label>
<input
type="url"
value={form.api_create_url || ""}
onChange={(e) => update("api_create_url", e.target.value)}
placeholder="https://api.wuyinkeji.com/api/async/image_nanoBanana2"
/>
<div className="hint">接口文档变更时可修改此处</div>
</div>
<div className="form-row">
<label>结果查询接口 URL</label>
<input
type="url"
value={form.api_result_url || ""}
onChange={(e) => update("api_result_url", e.target.value)}
placeholder="https://api.wuyinkeji.com/api/async/detail"
/>
</div>
</div>
<div className="card">
<h2>R2 图床上传参考图用</h2>
{R2_FIELDS.map(({ key, label, type }) => (
<div key={key} className="form-row">
<label>{label}</label>
<input
type={type || "text"}
value={form[key] || ""}
onChange={(e) => update(key, e.target.value)}
placeholder={label}
/>
</div>
))}
</div>
<div className="card">
<h2>默认保存目录</h2>
<div className="form-row">
<label>生图结果默认保存到此目录</label>
<div className="input-with-btn">
<input
type="text"
readOnly
value={form.default_save_dir || ""}
placeholder="未选择"
/>
<button type="button" className="btn btn-secondary" onClick={pickDir}>
选择文件夹
</button>
</div>
</div>
</div>
<div className="card">
<h2>自定义请求参数</h2>
<p className="form-hint">接口文档变更时可在此增加或修改请求体参数键值对</p>
{(form.extra_params || []).map((item, index) => (
<div key={index} className="extra-param-row">
<input
className="extra-key"
value={item?.key ?? ""}
onChange={(e) => updateExtra(index, "key", e.target.value)}
placeholder="参数名"
/>
<input
className="extra-value"
value={item?.value ?? ""}
onChange={(e) => updateExtra(index, "value", e.target.value)}
placeholder="参数值"
/>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => removeExtra(index)}
>
删除
</button>
</div>
))}
<button type="button" className="btn btn-secondary btn-sm" onClick={addExtra}>
+ 添加参数
</button>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleSave}
disabled={saving}
>
{saving ? "保存中…" : "保存配置"}
</button>
{message && <span className="form-message">{message}</span>}
</div>
</div>
);
}

44
src/index.css Normal file
View File

@@ -0,0 +1,44 @@
:root {
--bg: #0f0f12;
--bg-card: #18181c;
--border: #2a2a30;
--text: #e4e4e7;
--text-muted: #a1a1aa;
--accent: #a78bfa;
--accent-hover: #c4b5fd;
--success: #34d399;
--error: #f87171;
--radius: 10px;
--font: "Inter", "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
-webkit-app-region: no-drag;
}
#root {
min-height: 100vh;
padding: 20px;
}
input, textarea, select, button {
font-family: inherit;
}
button {
cursor: pointer;
}
.app-title {
-webkit-app-region: drag;
user-select: none;
}

13
src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

9
vite.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
build: { outDir: "dist" },
plugins: [react()],
server: { port: 5173 },
});