feat: init image studio app
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.server.err.log
|
||||
.server.out.log
|
||||
node_modules/
|
||||
29
README.md
Normal file
29
README.md
Normal 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
9
config.json
Normal 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
9
package.json
Normal 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
823
public/app.js
Normal 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
237
public/index.html
Normal 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
1031
public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
251
server.js
Normal file
251
server.js
Normal 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}`);
|
||||
});
|
||||
45
start.sh
Normal file
45
start.sh
Normal 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
|
||||
Reference in New Issue
Block a user