feat: init image studio app

This commit is contained in:
2026-04-23 18:55:38 +08:00
commit be535af6dc
10 changed files with 2440 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.server.err.log
.server.out.log
node_modules/

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# Image Studio
一个无第三方依赖的本地图像生成网页,适合对接兼容 `gpt-image-2` 的接口。
## 启动
```powershell
node server.js
```
启动后访问:
```text
http://127.0.0.1:3000
```
## 当前交互方式
- 左侧填写基础域名或完整请求 URL
- 当前浏览器会自动记住域名、API Key、参数和聊天记录
- 只填域名时,服务端会自动补全默认路径
- 如果填写的是带路径的完整 URL则按用户填写的路径请求
- 右侧使用多轮对话方式连续改图
- 返回图片固定显示为缩略图,点击后放大预览
## 说明
- 当前版本前端直接让客户自己填写域名,不依赖默认域名配置
- 根目录里的 `config.json` 目前不是运行必需项,属于历史遗留文件

9
config.json Normal file
View File

@@ -0,0 +1,9 @@
{
"defaultBaseUrl": "https://anthropic.edu.pl/v1/images/generations",
"baseUrlPresets": [
{
"label": "anthropic.edu.pl",
"url": "https://anthropic.edu.pl/v1/images/generations"
}
]
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "gpttoolsweb-image-studio",
"version": "1.0.0",
"private": true,
"description": "A lightweight image generation chat UI for gpt-image-2 compatible endpoints.",
"scripts": {
"start": "node server.js"
}
}

823
public/app.js Normal file
View File

@@ -0,0 +1,823 @@
const SETTINGS_STORAGE_KEY = "image-studio-settings-v2";
const LEGACY_SETTINGS_STORAGE_KEY = "image-studio-settings-v1";
const MEMORY_DB_NAME = "image-studio-local-memory";
const MEMORY_STORE_NAME = "app-cache";
const CHAT_HISTORY_KEY = "chat-history";
const DEFAULT_SETTINGS = {
baseUrl: "",
apiKey: "",
model: "gpt-image-2",
size: "1536x1024",
quality: "high",
n: 1,
timeoutSeconds: 300
};
const state = {
messages: [],
isLoading: false,
settings: loadSettings()
};
const BUTTON_IDLE_TEXT = "生成图片";
const BUTTON_LOADING_TEXT = "生成中";
let loadingTicker = null;
let memoryDbPromise = null;
const refs = {
baseUrlInput: document.querySelector("#baseUrlInput"),
apiKeyInput: document.querySelector("#apiKeyInput"),
modelInput: document.querySelector("#modelInput"),
sizeSelect: document.querySelector("#sizeSelect"),
qualitySelect: document.querySelector("#qualitySelect"),
countInput: document.querySelector("#countInput"),
timeoutInput: document.querySelector("#timeoutInput"),
messageInput: document.querySelector("#messageInput"),
messageList: document.querySelector("#messageList"),
promptPreview: document.querySelector("#promptPreview"),
composerForm: document.querySelector("#composerForm"),
sendButton: document.querySelector("#sendButton"),
clearChatButton: document.querySelector("#clearChatButton"),
resetSettingsButton: document.querySelector("#resetSettingsButton"),
statusBadge: document.querySelector("#statusBadge"),
messageTemplate: document.querySelector("#messageTemplate"),
lightbox: document.querySelector("#lightbox"),
lightboxImage: document.querySelector("#lightboxImage"),
lightboxLabel: document.querySelector("#lightboxLabel"),
lightboxDownloadLink: document.querySelector("#lightboxDownloadLink"),
lightboxCloseButton: document.querySelector("#lightboxCloseButton")
};
bootstrap();
function bootstrap() {
hydrateSettings();
bindEvents();
closeLightbox();
renderMessages();
updatePromptPreview();
setStatus("待命", "idle");
setSendButtonBusy(false);
restorePersistedMessages();
}
function loadSettings() {
try {
const primaryRaw = localStorage.getItem(SETTINGS_STORAGE_KEY);
const legacyRaw = localStorage.getItem(LEGACY_SETTINGS_STORAGE_KEY);
const raw = primaryRaw || legacyRaw;
if (!raw) {
return { ...DEFAULT_SETTINGS };
}
const parsed = JSON.parse(raw);
return {
...DEFAULT_SETTINGS,
...(parsed && typeof parsed === "object" ? parsed : {})
};
} catch (error) {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings() {
localStorage.setItem(
SETTINGS_STORAGE_KEY,
JSON.stringify({
baseUrl: refs.baseUrlInput.value.trim(),
apiKey: refs.apiKeyInput.value.trim(),
model: refs.modelInput.value.trim() || "gpt-image-2",
size: refs.sizeSelect.value,
quality: refs.qualitySelect.value,
n: clampCount(refs.countInput.value),
timeoutSeconds: clampTimeout(refs.timeoutInput.value)
})
);
}
function hydrateSettings() {
refs.baseUrlInput.value = state.settings.baseUrl;
refs.apiKeyInput.value = state.settings.apiKey;
refs.modelInput.value = state.settings.model;
refs.sizeSelect.value = state.settings.size;
refs.qualitySelect.value = state.settings.quality;
refs.countInput.value = String(state.settings.n);
refs.timeoutInput.value = String(state.settings.timeoutSeconds);
}
function bindEvents() {
[
refs.baseUrlInput,
refs.apiKeyInput,
refs.modelInput,
refs.sizeSelect,
refs.qualitySelect,
refs.countInput,
refs.timeoutInput
].forEach((element) => {
element.addEventListener("input", handleSettingsChange);
element.addEventListener("change", handleSettingsChange);
});
refs.messageInput.addEventListener("input", updatePromptPreview);
refs.messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
refs.composerForm.requestSubmit();
}
});
refs.composerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await handleGenerate();
});
refs.clearChatButton.addEventListener("click", () => {
state.messages = [];
stopLoadingTicker();
clearPersistedMessages();
renderMessages();
updatePromptPreview();
setStatus("待命", "idle");
});
refs.resetSettingsButton.addEventListener("click", () => {
state.settings = { ...DEFAULT_SETTINGS };
hydrateSettings();
saveSettings();
updatePromptPreview();
setStatus("待命", "idle");
});
refs.lightboxCloseButton.addEventListener("click", closeLightbox);
refs.lightbox.addEventListener("click", (event) => {
if (event.target instanceof HTMLElement && event.target.hasAttribute("data-close-lightbox")) {
closeLightbox();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && refs.lightbox.classList.contains("is-open")) {
closeLightbox();
}
});
document.querySelectorAll("[data-model]").forEach((button) => {
button.addEventListener("click", () => {
refs.modelInput.value = button.dataset.model || "gpt-image-2";
handleSettingsChange();
});
});
}
function handleSettingsChange() {
saveSettings();
updatePromptPreview();
}
function clampCount(value) {
const count = Number(value || 1);
if (!Number.isFinite(count)) {
return 1;
}
return Math.max(1, Math.min(Math.round(count), 4));
}
function clampTimeout(value) {
const seconds = Number(value || 300);
if (!Number.isFinite(seconds)) {
return 300;
}
return Math.max(30, Math.min(Math.round(seconds), 600));
}
function setStatus(text, tone = "idle") {
refs.statusBadge.textContent = text;
refs.statusBadge.dataset.state = tone;
}
function setSendButtonBusy(isBusy) {
refs.sendButton.disabled = isBusy;
refs.sendButton.classList.toggle("is-loading", isBusy);
refs.sendButton.textContent = isBusy ? BUTTON_LOADING_TEXT : BUTTON_IDLE_TEXT;
}
function formatElapsedLabel(totalSeconds) {
const seconds = Math.max(0, Math.floor(totalSeconds));
const minutes = Math.floor(seconds / 60);
const remainSeconds = seconds % 60;
if (minutes === 0) {
return `${remainSeconds}`;
}
return `${minutes}${remainSeconds}`;
}
function getLoadingMeta(startedAt) {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
const elapsedLabel = formatElapsedLabel(elapsedSeconds);
if (elapsedSeconds < 6) {
return {
elapsedSeconds,
elapsedLabel,
phaseTitle: "正在整理上下文",
phaseDetail: "把前面的多轮要求整理成最终提示词,再发给图像接口。",
statusText: "生成中"
};
}
if (elapsedSeconds < 18) {
return {
elapsedSeconds,
elapsedLabel,
phaseTitle: "请求已发送",
phaseDetail: "图像服务正在处理请求,请保持页面开启。",
statusText: `生成中 ${elapsedLabel}`
};
}
return {
elapsedSeconds,
elapsedLabel,
phaseTitle: "正在等待图片返回",
phaseDetail: "图像生成通常需要 30 到 60 秒,网络较慢时会更久一些。",
statusText: `生成中 ${elapsedLabel}`
};
}
function createLoadingCard(startedAt) {
const card = document.createElement("section");
card.className = "generation-loading";
card.setAttribute("data-loading-card", "true");
card.innerHTML = `
<div class="generation-loading-top">
<div>
<p class="generation-loading-label" data-loading-phase></p>
<p class="generation-loading-detail" data-loading-detail></p>
</div>
<span class="generation-loading-elapsed" data-loading-elapsed></span>
</div>
<div class="generation-loading-visual" aria-hidden="true">
<div class="generation-loading-shimmer"></div>
<div class="generation-loading-orb"></div>
<div class="generation-loading-grid"></div>
</div>
<div class="generation-loading-steps">
<span class="generation-loading-pill" data-step-index="0">整理上下文</span>
<span class="generation-loading-pill" data-step-index="1">提交请求</span>
<span class="generation-loading-pill" data-step-index="2">等待图片返回</span>
</div>
`;
updateLoadingCard(card, startedAt);
return card;
}
function updateLoadingCard(card, startedAt) {
if (!card) {
return;
}
const meta = getLoadingMeta(startedAt);
const activeStep = meta.elapsedSeconds < 6 ? 0 : meta.elapsedSeconds < 18 ? 1 : 2;
const phaseNode = card.querySelector("[data-loading-phase]");
const detailNode = card.querySelector("[data-loading-detail]");
const elapsedNode = card.querySelector("[data-loading-elapsed]");
if (phaseNode) {
phaseNode.textContent = meta.phaseTitle;
}
if (detailNode) {
detailNode.textContent = meta.phaseDetail;
}
if (elapsedNode) {
elapsedNode.textContent = `已等待 ${meta.elapsedLabel}`;
}
card.querySelectorAll("[data-step-index]").forEach((node) => {
const stepIndex = Number(node.getAttribute("data-step-index"));
node.classList.toggle("is-active", stepIndex === activeStep);
node.classList.toggle("is-done", stepIndex < activeStep);
});
}
function syncLoadingUi() {
const loadingMessage = state.messages.find((message) => message.loading);
if (!loadingMessage || !loadingMessage.loadingStartedAt) {
stopLoadingTicker();
return;
}
const loadingCard = refs.messageList.querySelector(
`.message[data-message-id="${loadingMessage.id}"] [data-loading-card]`
);
if (loadingCard) {
updateLoadingCard(loadingCard, loadingMessage.loadingStartedAt);
}
const meta = getLoadingMeta(loadingMessage.loadingStartedAt);
setStatus(meta.statusText, "loading");
}
function startLoadingTicker() {
stopLoadingTicker();
syncLoadingUi();
loadingTicker = window.setInterval(syncLoadingUi, 1000);
}
function stopLoadingTicker() {
if (loadingTicker !== null) {
window.clearInterval(loadingTicker);
loadingTicker = null;
}
}
function supportsIndexedDb() {
return typeof window !== "undefined" && "indexedDB" in window;
}
function openMemoryDb() {
if (!supportsIndexedDb()) {
return Promise.resolve(null);
}
if (memoryDbPromise) {
return memoryDbPromise;
}
memoryDbPromise = new Promise((resolve) => {
const request = window.indexedDB.open(MEMORY_DB_NAME, 1);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(MEMORY_STORE_NAME)) {
database.createObjectStore(MEMORY_STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => resolve(null);
request.onblocked = () => resolve(null);
});
return memoryDbPromise;
}
function buildPersistedMessagesSnapshot(messages) {
return messages.map((message) => {
const images = Array.isArray(message.images)
? message.images
.filter((image) => image && typeof image.src === "string")
.map((image, index) => ({
src: image.src,
label: typeof image.label === "string" ? image.label : `生成图片 ${index + 1}`,
filename:
typeof image.filename === "string" && image.filename
? image.filename
: `image-${Date.now()}-${index + 1}.png`
}))
: [];
return {
id: typeof message.id === "string" ? message.id : generateId(),
role: message.role === "assistant" ? "assistant" : "user",
time: typeof message.time === "string" ? message.time : getNowLabel(),
text: typeof message.text === "string" ? message.text : "",
extra: typeof message.extra === "string" ? message.extra : "",
images,
loading: Boolean(message.loading),
loadingStartedAt:
typeof message.loadingStartedAt === "number" && Number.isFinite(message.loadingStartedAt)
? message.loadingStartedAt
: null
};
});
}
function normalizePersistedMessages(payload) {
const items = Array.isArray(payload) ? payload : Array.isArray(payload?.items) ? payload.items : [];
return items
.map((message) => {
const normalized = {
id: typeof message?.id === "string" ? message.id : generateId(),
role: message?.role === "assistant" ? "assistant" : "user",
time: typeof message?.time === "string" ? message.time : getNowLabel(),
text: typeof message?.text === "string" ? message.text : "",
extra: typeof message?.extra === "string" ? message.extra : "",
images: Array.isArray(message?.images)
? message.images
.filter((image) => image && typeof image.src === "string")
.map((image, index) => ({
src: image.src,
label: typeof image.label === "string" ? image.label : `生成图片 ${index + 1}`,
filename:
typeof image.filename === "string" && image.filename
? image.filename
: `image-${Date.now()}-${index + 1}.png`
}))
: [],
loading: false
};
if (message?.loading) {
normalized.text = "上次有一条未完成的生成请求,页面刷新后已中断。";
normalized.extra = [normalized.extra, "如需继续,请重新发送这一轮需求。"].filter(Boolean).join(" ");
normalized.images = [];
}
return normalized;
})
.filter((message) => message.text || message.extra || message.images.length > 0);
}
async function readPersistedMessages() {
const database = await openMemoryDb();
if (!database) {
return [];
}
return new Promise((resolve) => {
const transaction = database.transaction(MEMORY_STORE_NAME, "readonly");
const store = transaction.objectStore(MEMORY_STORE_NAME);
const request = store.get(CHAT_HISTORY_KEY);
request.onsuccess = () => resolve(normalizePersistedMessages(request.result));
request.onerror = () => resolve([]);
transaction.onabort = () => resolve([]);
});
}
async function persistMessages() {
const database = await openMemoryDb();
if (!database) {
return;
}
const snapshot = {
updatedAt: Date.now(),
items: buildPersistedMessagesSnapshot(state.messages)
};
await new Promise((resolve) => {
const transaction = database.transaction(MEMORY_STORE_NAME, "readwrite");
transaction.objectStore(MEMORY_STORE_NAME).put(snapshot, CHAT_HISTORY_KEY);
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
transaction.onabort = () => resolve();
});
}
async function clearPersistedMessages() {
const database = await openMemoryDb();
if (!database) {
return;
}
await new Promise((resolve) => {
const transaction = database.transaction(MEMORY_STORE_NAME, "readwrite");
transaction.objectStore(MEMORY_STORE_NAME).delete(CHAT_HISTORY_KEY);
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
transaction.onabort = () => resolve();
});
}
async function restorePersistedMessages() {
const messages = await readPersistedMessages();
if (!Array.isArray(messages) || messages.length === 0) {
return;
}
state.messages = messages;
renderMessages();
updatePromptPreview();
persistMessages();
}
function getNowLabel() {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit"
}).format(new Date());
}
function generateId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function createMessage(message) {
return {
id: generateId(),
time: getNowLabel(),
...message
};
}
function addMessage(message) {
const nextMessage = createMessage(message);
state.messages.push(nextMessage);
renderMessages();
updatePromptPreview();
persistMessages();
return nextMessage;
}
function replaceMessage(messageId, updater) {
const index = state.messages.findIndex((item) => item.id === messageId);
if (index === -1) {
return;
}
state.messages[index] = {
...state.messages[index],
...updater
};
renderMessages();
updatePromptPreview();
persistMessages();
}
function buildContextPrompt(nextUserText) {
const turns = state.messages
.filter((message) => message.role === "user")
.map((message, index) => `${index + 1} 轮用户需求:${message.text}`);
if (nextUserText.trim()) {
turns.push(`当前新需求:${nextUserText.trim()}`);
}
if (turns.length === 0) {
return "请生成一张视觉完成度高的图片。";
}
return [
"请把下面的多轮中文对话整理为一条最终图像生成提示词。",
"要求:保留前文已确认设定;如果最新需求与之前冲突,以最新需求为准;输出时直接按最终画面理解,不要解释。",
"",
turns.join("\n"),
"",
"请直接据此生成图片。"
].join("\n");
}
function updatePromptPreview() {
refs.promptPreview.textContent = buildContextPrompt(refs.messageInput.value);
}
function formatGenerateError(error) {
const rawMessage = String(error && error.message ? error.message : error || "未知错误");
if (/failed to fetch/i.test(rawMessage)) {
return "浏览器没有成功访问当前站点的 /api/images/generate。这个报错通常不是上游图片接口跨域而是当前站点的 HTTPS、反向代理或端口转发没有配通。若你在 1Panel 里做反代,目标一般应写成 http://127.0.0.1:3000而不是 https://127.0.0.1:3000。";
}
return rawMessage;
}
function openLightbox(image) {
refs.lightboxImage.src = image.src;
refs.lightboxLabel.textContent = image.label;
refs.lightboxDownloadLink.href = image.src;
refs.lightboxDownloadLink.download = image.filename;
refs.lightbox.classList.add("is-open");
refs.lightbox.setAttribute("aria-hidden", "false");
document.body.classList.add("is-lightbox-open");
}
function closeLightbox() {
refs.lightbox.classList.remove("is-open");
refs.lightbox.setAttribute("aria-hidden", "true");
refs.lightboxImage.removeAttribute("src");
refs.lightboxLabel.textContent = "";
refs.lightboxDownloadLink.href = "/";
refs.lightboxDownloadLink.download = "preview.png";
document.body.classList.remove("is-lightbox-open");
}
function createImageCard(image, index) {
const card = document.createElement("div");
card.className = "image-card";
const previewButton = document.createElement("button");
previewButton.type = "button";
previewButton.className = "image-preview-button";
previewButton.setAttribute("aria-label", `查看大图 ${index + 1}`);
previewButton.addEventListener("click", () => {
openLightbox(image);
});
const previewImage = document.createElement("img");
previewImage.alt = `生成图片 ${index + 1}`;
previewImage.src = image.src;
const previewHint = document.createElement("span");
previewHint.className = "image-preview-hint";
previewHint.textContent = "点击放大";
previewButton.appendChild(previewImage);
previewButton.appendChild(previewHint);
const footer = document.createElement("div");
footer.className = "image-card-footer";
const label = document.createElement("span");
label.className = "image-label";
label.textContent = image.label;
const downloadLink = document.createElement("a");
downloadLink.className = "download-link";
downloadLink.href = image.src;
downloadLink.download = image.filename;
downloadLink.textContent = "下载 PNG";
footer.appendChild(label);
footer.appendChild(downloadLink);
card.appendChild(previewButton);
card.appendChild(footer);
return card;
}
function renderMessages() {
refs.messageList.innerHTML = "";
if (state.messages.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.innerHTML = `
<h3>像和 ChatGPT 一样聊图</h3>
<p>左侧填写基础 URL 和 Key右侧输入图像需求。后续继续追加修改要求系统会自动带上上下文。</p>
`;
refs.messageList.appendChild(empty);
return;
}
state.messages.forEach((message) => {
const fragment = refs.messageTemplate.content.cloneNode(true);
const article = fragment.querySelector(".message");
const roleNode = fragment.querySelector(".message-role");
const timeNode = fragment.querySelector(".message-time");
const textNode = fragment.querySelector(".message-text");
const imagesNode = fragment.querySelector(".message-images");
const extraNode = fragment.querySelector(".message-extra");
article.dataset.messageId = message.id;
article.classList.add(message.role);
if (message.loading) {
article.classList.add("is-loading");
}
roleNode.textContent = message.role === "user" ? "你" : "助手";
timeNode.textContent = message.time;
textNode.textContent = message.text;
if (Array.isArray(message.images) && message.images.length > 0) {
message.images.forEach((image, index) => {
imagesNode.appendChild(createImageCard(image, index));
});
}
if (message.loading && message.loadingStartedAt) {
textNode.setAttribute("aria-live", "polite");
textNode.insertAdjacentElement("afterend", createLoadingCard(message.loadingStartedAt));
}
if (message.extra) {
extraNode.textContent = message.extra;
} else {
extraNode.remove();
}
refs.messageList.appendChild(fragment);
});
refs.messageList.scrollTop = refs.messageList.scrollHeight;
}
async function handleGenerate() {
if (state.isLoading) {
return;
}
const userText = refs.messageInput.value.trim();
const apiKey = refs.apiKeyInput.value.trim();
const baseUrl = refs.baseUrlInput.value.trim();
if (!userText) {
refs.messageInput.focus();
return;
}
if (!baseUrl) {
setStatus("缺少 URL", "error");
refs.baseUrlInput.focus();
return;
}
if (!apiKey) {
setStatus("缺少 Key", "error");
refs.apiKeyInput.focus();
return;
}
addMessage({
role: "user",
text: userText
});
refs.messageInput.value = "";
updatePromptPreview();
const composedPrompt = buildContextPrompt("");
const assistantPlaceholder = addMessage({
role: "assistant",
text: "正在生成图像,请保持页面开启。",
extra: "本轮会自动携带前文上下文。",
loading: true,
loadingStartedAt: Date.now()
});
state.isLoading = true;
setSendButtonBusy(true);
startLoadingTicker();
try {
const response = await fetch("/api/images/generate", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
baseUrl,
apiKey,
model: refs.modelInput.value.trim() || "gpt-image-2",
prompt: composedPrompt,
size: refs.sizeSelect.value,
quality: refs.qualitySelect.value,
n: clampCount(refs.countInput.value),
timeoutMs: clampTimeout(refs.timeoutInput.value) * 1000
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "生成失败");
}
const images = Array.isArray(result.data)
? result.data
.filter((item) => item && item.b64_json)
.map((item, index) => ({
src: `data:image/png;base64,${item.b64_json}`,
label: `${refs.sizeSelect.value} · ${refs.qualitySelect.value} · 第 ${index + 1}`,
filename: `image-${Date.now()}-${index + 1}.png`
}))
: [];
replaceMessage(assistantPlaceholder.id, {
text: images.length > 0 ? `已完成生成,共返回 ${images.length} 张图片。` : "接口返回成功,但没有拿到图片数据。",
images,
extra: [
`模型:${refs.modelInput.value.trim() || "gpt-image-2"}`,
`尺寸:${refs.sizeSelect.value}`,
`质量:${refs.qualitySelect.value}`,
`上下文轮次:${state.messages.filter((message) => message.role === "user").length}`
].join(" | "),
loading: false
});
stopLoadingTicker();
setStatus("已完成", "success");
} catch (error) {
replaceMessage(assistantPlaceholder.id, {
text: "生成失败,请检查接口配置后重试。",
extra: formatGenerateError(error),
loading: false
});
stopLoadingTicker();
setStatus("失败", "error");
} finally {
state.isLoading = false;
setSendButtonBusy(false);
}
}

237
public/index.html Normal file
View File

@@ -0,0 +1,237 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Studio</title>
<link rel="stylesheet" href="/styles.css?v=20260423-4" />
</head>
<body>
<div class="page-shell">
<aside class="control-panel">
<div class="panel-head">
<p class="eyebrow">图像工作台</p>
<h1>Image Studio</h1>
<p class="lead">
配置兼容 <code>gpt-image-2</code> 的图像接口,右侧直接多轮对话生成并连续微调。
</p>
</div>
<section class="config-group">
<div class="group-title-row">
<h2>接口配置</h2>
<button id="resetSettingsButton" class="text-button" type="button">恢复默认</button>
</div>
<label class="field">
<span>基础域名</span>
<input id="baseUrlInput" type="url" placeholder="https://域名" />
<small class="field-hint">只填域名时自动使用 <code>/v1/images/generations</code>;填了带路径的完整 URL则使用你自定义的路径。</small>
</label>
<div id="baseUrlPresetList" class="preset-wrap single-option-row"></div>
<label class="field">
<span>API Key</span>
<input id="apiKeyInput" type="password" placeholder="sk-xxx" autocomplete="off" />
<small class="field-hint">会保存在当前浏览器中,下次打开自动回填。若是共享电脑,点“恢复默认”即可清空。</small>
</label>
</section>
<section class="config-group">
<h2>生成参数</h2>
<label class="field">
<span>模型</span>
<input id="modelInput" type="text" list="modelSuggestions" placeholder="gpt-image-2" />
<datalist id="modelSuggestions">
<option value="gpt-image-2"></option>
</datalist>
</label>
<div class="preset-wrap single-option-row">
<button class="preset-button" type="button" data-model="gpt-image-2">gpt-image-2</button>
</div>
<div class="grid-fields">
<label class="field">
<span>尺寸</span>
<select id="sizeSelect">
<option value="1024x1024">1024 x 1024</option>
<option value="1536x1024">1536 x 1024</option>
<option value="1024x1536">1024 x 1536</option>
</select>
</label>
<label class="field">
<span>质量</span>
<select id="qualitySelect">
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
</label>
</div>
<div class="grid-fields">
<label class="field">
<span>数量</span>
<input id="countInput" type="number" min="1" max="4" step="1" />
</label>
<label class="field">
<span>超时(秒)</span>
<input id="timeoutInput" type="number" min="30" max="600" step="10" />
</label>
</div>
</section>
<section class="config-group">
<h2>多轮上下文</h2>
<p class="helper-text">
右侧每次发送的新需求都会自动拼接前文,适合让客户持续改图,比如“保留构图,换成手绘风”。
</p>
<p class="helper-text">
当前浏览器会自动记住域名、Key、参数和聊天记录关闭页面后再次打开会自动恢复。
</p>
<div class="context-card">
<p class="context-label">最终 Prompt 规则</p>
<ul class="rule-list">
<li>保留之前已经确认的设定</li>
<li>最新一轮要求优先级最高</li>
<li>自动整理成最终图像提示词再发送</li>
</ul>
</div>
<details class="doc-card">
<summary>请求 URL</summary>
<ul id="requestUrlList" class="url-list"></ul>
</details>
<details class="doc-card">
<summary>请求体参数</summary>
<div class="table-wrap">
<table class="param-table">
<thead>
<tr>
<th>字段</th>
<th>说明</th>
<th>可选值</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>model</code></td>
<td>模型名</td>
<td><code>gpt-image-2</code></td>
</tr>
<tr>
<td><code>prompt</code></td>
<td>提示词</td>
<td>任意文本</td>
</tr>
<tr>
<td><code>size</code></td>
<td>尺寸</td>
<td><code>1024x1024</code> / <code>1536x1024</code> / <code>1024x1536</code></td>
</tr>
<tr>
<td><code>quality</code></td>
<td>质量</td>
<td><code>low</code> / <code>medium</code> / <code>high</code></td>
</tr>
<tr>
<td><code>n</code></td>
<td>生成数量</td>
<td><code>1 ~ N</code></td>
</tr>
</tbody>
</table>
</div>
</details>
<details class="doc-card">
<summary>响应格式</summary>
<pre class="api-pre">{
"created": 1776911385,
"data": [
{
"b64_json": "iVBORw0KGgoAAA..."
}
]
}</pre>
<p class="note-text">
<code>data[].b64_json</code> 为 base64 PNG。请求耗时通常 30 到 60 秒,建议超时至少设置为 180 秒。
</p>
</details>
</section>
</aside>
<main class="chat-panel">
<header class="chat-header">
<div>
<p class="eyebrow">Conversation</p>
<h2>图像对话区</h2>
</div>
<div class="header-actions">
<span id="statusBadge" class="status-badge">待命</span>
<button id="clearChatButton" class="ghost-button" type="button">清空上下文</button>
</div>
</header>
<section id="messageList" class="message-list" aria-live="polite"></section>
<section class="composer-panel">
<details class="prompt-preview">
<summary>本次发送的最终 Prompt 预览</summary>
<pre id="promptPreview"></pre>
</details>
<form id="composerForm" class="composer-form">
<textarea
id="messageInput"
rows="4"
placeholder="例如:生成一个美女骑大猫咪飞天。下一轮你可以继续说:保留构图,改成古风手绘,云层更厚一点。"
></textarea>
<div class="composer-actions">
<p class="tip-text">按 Enter 发送Shift + Enter 换行</p>
<button id="sendButton" class="primary-button" type="submit">生成图片</button>
</div>
</form>
</section>
</main>
</div>
<template id="messageTemplate">
<article class="message">
<div class="avatar"></div>
<div class="message-body">
<div class="message-meta">
<strong class="message-role"></strong>
<span class="message-time"></span>
</div>
<p class="message-text"></p>
<div class="message-images"></div>
<div class="message-extra"></div>
</div>
</article>
</template>
<div id="lightbox" class="lightbox" aria-hidden="true">
<div class="lightbox-backdrop" data-close-lightbox></div>
<div class="lightbox-dialog" role="dialog" aria-modal="true" aria-label="图片预览">
<button id="lightboxCloseButton" class="lightbox-close" type="button" aria-label="关闭预览">×</button>
<div class="lightbox-image-wrap">
<img id="lightboxImage" alt="放大预览图" />
</div>
<div class="lightbox-footer">
<span id="lightboxLabel" class="lightbox-label"></span>
<a id="lightboxDownloadLink" class="download-link" href="/" download="preview.png">下载 PNG</a>
</div>
</div>
</div>
<script src="/app.js?v=20260423-4" defer></script>
</body>
</html>

1031
public/styles.css Normal file

File diff suppressed because it is too large Load Diff

251
server.js Normal file
View File

@@ -0,0 +1,251 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const HOST = process.env.HOST || "0.0.0.0";
const PORT = Number(process.env.PORT || 3000);
const PUBLIC_DIR = path.join(__dirname, "public");
const DEFAULT_ENDPOINT_PATH = "/v1/images/generations";
const COMMON_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400"
};
const MIME_TYPES = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml"
};
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
...COMMON_HEADERS,
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store"
});
response.end(JSON.stringify(payload));
}
function normalizeUrlInput(input) {
if (typeof input !== "string") {
return "";
}
return input.trim();
}
function toTargetEndpoint(input) {
const raw = normalizeUrlInput(input);
if (!raw) {
return {
ok: false,
error: "请先填写基础 URL。"
};
}
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
let parsedUrl;
try {
parsedUrl = new URL(withScheme);
} catch (error) {
return {
ok: false,
error: "基础 URL 格式不正确。支持填写域名或完整 URL。"
};
}
if (!parsedUrl.pathname || parsedUrl.pathname === "/") {
parsedUrl.pathname = DEFAULT_ENDPOINT_PATH;
}
return {
ok: true,
targetUrl: parsedUrl.toString()
};
}
function getStaticFilePath(urlPathname) {
const safePath = urlPathname === "/" ? "/index.html" : urlPathname;
const normalized = path.normalize(safePath).replace(/^(\.\.[/\\])+/, "");
return path.join(PUBLIC_DIR, normalized);
}
async function readRequestBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on("data", (chunk) => {
chunks.push(chunk);
});
request.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
request.on("error", reject);
});
}
async function proxyImageGeneration(request, response) {
let payload;
try {
const body = await readRequestBody(request);
payload = JSON.parse(body || "{}");
} catch (error) {
sendJson(response, 400, {
error: "请求体不是合法的 JSON。"
});
return;
}
const endpoint = toTargetEndpoint(payload.baseUrl);
const apiKey = typeof payload.apiKey === "string" ? payload.apiKey.trim() : "";
const model = typeof payload.model === "string" && payload.model.trim() ? payload.model.trim() : "gpt-image-2";
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() : "";
const size = typeof payload.size === "string" && payload.size.trim() ? payload.size.trim() : "1024x1024";
const quality = typeof payload.quality === "string" && payload.quality.trim() ? payload.quality.trim() : "high";
const n = Number(payload.n || 1);
const timeoutMs = Math.max(30_000, Math.min(Number(payload.timeoutMs || 300_000), 600_000));
if (!endpoint.ok) {
sendJson(response, 400, { error: endpoint.error });
return;
}
if (!apiKey) {
sendJson(response, 400, { error: "请先填写 API Key。" });
return;
}
if (!prompt) {
sendJson(response, 400, { error: "请输入要生成的内容。" });
return;
}
if (!Number.isFinite(n) || n < 1 || n > 10) {
sendJson(response, 400, { error: "生成数量需要在 1 到 10 之间。" });
return;
}
let upstreamResponse;
let upstreamText = "";
try {
upstreamResponse = await fetch(endpoint.targetUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model,
prompt,
size,
quality,
n
}),
signal: AbortSignal.timeout(timeoutMs)
});
upstreamText = await upstreamResponse.text();
} catch (error) {
const isTimeout = error && error.name === "TimeoutError";
sendJson(response, 502, {
error: isTimeout ? "上游接口超时,请调大超时时间后重试。" : "请求上游接口失败,请检查 URL 或网络配置。",
details: String(error && error.message ? error.message : error)
});
return;
}
let upstreamJson;
try {
upstreamJson = upstreamText ? JSON.parse(upstreamText) : {};
} catch (error) {
sendJson(response, 502, {
error: "上游接口返回的不是合法 JSON。",
details: upstreamText.slice(0, 1000)
});
return;
}
if (!upstreamResponse.ok) {
sendJson(response, upstreamResponse.status, {
error: upstreamJson.error?.message || upstreamJson.error || "上游接口返回错误。",
upstream: upstreamJson
});
return;
}
sendJson(response, 200, upstreamJson);
}
function serveStaticFile(requestPath, response) {
const filePath = getStaticFilePath(requestPath);
if (!filePath.startsWith(PUBLIC_DIR)) {
sendJson(response, 403, { error: "禁止访问。" });
return;
}
fs.readFile(filePath, (error, content) => {
if (error) {
if (error.code === "ENOENT") {
sendJson(response, 404, { error: "页面不存在。" });
return;
}
sendJson(response, 500, { error: "读取静态文件失败。" });
return;
}
const extension = path.extname(filePath).toLowerCase();
response.writeHead(200, {
...COMMON_HEADERS,
"Content-Type": MIME_TYPES[extension] || "application/octet-stream",
"Cache-Control": "no-store"
});
response.end(content);
});
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
if (request.method === "OPTIONS") {
response.writeHead(204, COMMON_HEADERS);
response.end();
return;
}
if (request.method === "POST" && url.pathname === "/api/images/generate") {
await proxyImageGeneration(request, response);
return;
}
if (request.method === "GET" && url.pathname === "/api/health") {
sendJson(response, 200, {
ok: true,
port: PORT,
endpointPath: DEFAULT_ENDPOINT_PATH
});
return;
}
if (request.method === "GET") {
serveStaticFile(url.pathname, response);
return;
}
sendJson(response, 405, { error: "Method Not Allowed" });
});
server.listen(PORT, HOST, () => {
console.log(`Image Studio running at http://${HOST}:${PORT}`);
});

3
start.bat Normal file
View File

@@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
node server.js

45
start.sh Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
export PORT=4601
PID_FILE="./server.pid"
OUT_LOG="./server.out.log"
ERR_LOG="./server.err.log"
# 停掉旧进程
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE")
if kill -0 "$OLD_PID" 2>/dev/null; then
echo "停止旧进程 PID=$OLD_PID"
kill "$OLD_PID" 2>/dev/null || true
sleep 1
kill -9 "$OLD_PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# 兜底:清理占用端口的残留
if command -v lsof >/dev/null 2>&1; then
STRAY=$(lsof -ti tcp:"$PORT" || true)
if [ -n "$STRAY" ]; then
echo "清理端口 $PORT 残留进程: $STRAY"
kill -9 $STRAY 2>/dev/null || true
fi
fi
# 后台启动
nohup node server.js >"$OUT_LOG" 2>"$ERR_LOG" &
NEW_PID=$!
echo $NEW_PID > "$PID_FILE"
sleep 1
if kill -0 "$NEW_PID" 2>/dev/null; then
echo "已启动 PID=$NEW_PID 端口=$PORT"
echo "日志: tail -f $OUT_LOG"
else
echo "启动失败,请查看 $ERR_LOG"
tail -n 20 "$ERR_LOG" 2>/dev/null || true
exit 1
fi