From be535af6dc331720e5597de166e9adaade7db1ba Mon Sep 17 00:00:00 2001 From: huangzhenpc Date: Thu, 23 Apr 2026 18:55:38 +0800 Subject: [PATCH] feat: init image studio app --- .gitignore | 3 + README.md | 29 ++ config.json | 9 + package.json | 9 + public/app.js | 823 ++++++++++++++++++++++++++++++++++++ public/index.html | 237 +++++++++++ public/styles.css | 1031 +++++++++++++++++++++++++++++++++++++++++++++ server.js | 251 +++++++++++ start.bat | 3 + start.sh | 45 ++ 10 files changed, 2440 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 server.js create mode 100644 start.bat create mode 100644 start.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddd75ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.server.err.log +.server.out.log +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddc9211 --- /dev/null +++ b/README.md @@ -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` 目前不是运行必需项,属于历史遗留文件 diff --git a/config.json b/config.json new file mode 100644 index 0000000..3edea4d --- /dev/null +++ b/config.json @@ -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" + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8b9286 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..6e1ad59 --- /dev/null +++ b/public/app.js @@ -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 = ` +
+
+

+

+
+ +
+ +
+ 整理上下文 + 提交请求 + 等待图片返回 +
+ `; + 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 = ` +

像和 ChatGPT 一样聊图

+

左侧填写基础 URL 和 Key,右侧输入图像需求。后续继续追加修改要求,系统会自动带上上下文。

+ `; + 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); + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..494e85b --- /dev/null +++ b/public/index.html @@ -0,0 +1,237 @@ + + + + + + Image Studio + + + +
+ + +
+
+
+

Conversation

+

图像对话区

+
+
+ 待命 + +
+
+ +
+ +
+
+ 本次发送的最终 Prompt 预览 +

+          
+ +
+ +
+

按 Enter 发送,Shift + Enter 换行

+ +
+
+
+
+
+ + + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..8a8deeb --- /dev/null +++ b/public/styles.css @@ -0,0 +1,1031 @@ +:root { + color-scheme: light; + --bg: #f4f1ea; + --panel: rgba(255, 255, 255, 0.86); + --panel-strong: rgba(255, 255, 255, 0.96); + --line: rgba(25, 31, 26, 0.08); + --line-strong: rgba(25, 31, 26, 0.14); + --text: #151b17; + --muted: #66726b; + --accent: #0f9d74; + --accent-strong: #0b7a5a; + --shadow: 0 18px 45px rgba(31, 43, 36, 0.08); + --radius-xl: 28px; + --radius-lg: 22px; + --radius-md: 16px; + --font-sans: "IBM Plex Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-display: "Space Grotesk", "IBM Plex Sans", "PingFang SC", sans-serif; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + height: 100%; + min-height: 100%; + font-family: var(--font-sans); + color: var(--text); + background: + radial-gradient(circle at top left, rgba(15, 157, 116, 0.12), transparent 32%), + radial-gradient(circle at bottom right, rgba(55, 128, 98, 0.08), transparent 28%), + linear-gradient(180deg, #f8f6f2 0%, var(--bg) 100%); +} + +body { + min-height: 100vh; + min-height: 100dvh; + height: 100vh; + height: 100dvh; + overflow: hidden; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +code, +pre { + font-family: "IBM Plex Mono", "Cascadia Code", monospace; +} + +.page-shell { + display: grid; + grid-template-columns: 332px minmax(0, 1fr); + gap: 16px; + padding: 16px; + min-height: 100vh; + min-height: 100dvh; + height: 100vh; + height: 100dvh; + min-height: 0; + align-items: stretch; + overflow: hidden; +} + +.control-panel, +.chat-panel { + border: 1px solid var(--line); + background: var(--panel); + backdrop-filter: blur(18px); + box-shadow: var(--shadow); + min-height: 0; +} + +.control-panel { + padding: 24px 20px; + border-radius: var(--radius-xl); + display: flex; + flex-direction: column; + gap: 14px; + height: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.panel-head h1, +.chat-header h2 { + margin: 6px 0 0; + font-family: var(--font-display); + font-size: 1.8rem; + line-height: 1.05; + letter-spacing: -0.05em; +} + +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lead, +.helper-text, +.tip-text { + margin: 0; + color: var(--muted); + line-height: 1.6; + font-size: 0.95rem; +} + +.field-hint { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.5; +} + +.field-hint code { + background: rgba(0, 0, 0, 0.05); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.72rem; +} + +.config-group { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 16px; + background: var(--panel-strong); +} + +.config-group h2 { + margin: 0 0 12px; + font-size: 0.98rem; +} + +.group-title-row, +.header-actions, +.composer-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.field:last-child { + margin-bottom: 0; +} + +.field span, +.context-label { + font-size: 0.88rem; + font-weight: 600; +} + +.field input, +.field select, +.field textarea, +.composer-form textarea { + border: 1px solid var(--line); + border-radius: 16px; + padding: 10px 12px; + background: #fff; + color: var(--text); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.field input:focus, +.field select:focus, +.composer-form textarea:focus { + border-color: rgba(15, 157, 116, 0.6); + box-shadow: 0 0 0 4px rgba(15, 157, 116, 0.12); +} + +.preset-wrap, +.grid-fields { + display: grid; + gap: 10px; +} + +.preset-wrap { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: -2px 0 12px; +} + +.single-option-row { + grid-template-columns: minmax(0, 1fr); +} + +.grid-fields { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.preset-button, +.ghost-button, +.text-button, +.primary-button, +.download-link { + border: none; + border-radius: 14px; + transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease; +} + +.preset-button, +.ghost-button, +.text-button { + background: rgba(15, 27, 23, 0.05); + color: var(--text); +} + +.preset-button:hover, +.ghost-button:hover, +.text-button:hover, +.primary-button:hover, +.download-link:hover { + transform: translateY(-1px); +} + +.preset-button { + padding: 9px 10px; +} + +.text-button, +.ghost-button { + padding: 8px 11px; +} + +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 11px 16px; + background: linear-gradient(135deg, var(--accent) 0%, #1f7a5f 100%); + color: #fff; + font-weight: 700; +} + +.primary-button[disabled] { + cursor: wait; + opacity: 0.68; +} + +.context-card { + padding: 12px; + border-radius: 18px; + background: linear-gradient(180deg, rgba(15, 157, 116, 0.08), rgba(15, 157, 116, 0.03)); + border: 1px solid rgba(15, 157, 116, 0.14); +} + +.rule-list { + margin: 10px 0 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.7; +} + +.doc-card { + margin-top: 12px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.84); + overflow: hidden; +} + +.doc-card summary { + padding: 12px 14px; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + user-select: none; +} + +.doc-card[open] summary { + border-bottom: 1px solid var(--line); +} + +.url-list { + margin: 0; + padding: 12px 18px 14px 32px; + color: var(--muted); + line-height: 1.7; +} + +.url-list code, +.param-table code, +.api-pre code, +.note-text code { + word-break: break-all; +} + +.table-wrap { + overflow: auto; +} + +.param-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.param-table th, +.param-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + line-height: 1.6; +} + +.param-table th { + color: var(--text); + background: rgba(15, 27, 23, 0.03); +} + +.param-table tbody tr:last-child td { + border-bottom: none; +} + +.api-pre { + margin: 0; + padding: 14px; + overflow: auto; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.7; + background: rgba(15, 27, 23, 0.03); +} + +.note-text { + margin: 0; + padding: 0 14px 14px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.7; +} + +.chat-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100%; + min-height: 0; + border-radius: 34px; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 16px; + border-bottom: 1px solid var(--line); + background: rgba(248, 248, 245, 0.82); +} + +.status-badge { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 70px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(15, 157, 116, 0.1); + color: var(--accent-strong); + font-size: 0.88rem; + font-weight: 700; +} + +.status-badge[data-state="success"] { + background: rgba(15, 157, 116, 0.14); + color: var(--accent-strong); +} + +.status-badge[data-state="error"] { + background: rgba(176, 61, 61, 0.12); + color: #a63d3d; +} + +.status-badge[data-state="loading"] { + background: rgba(15, 157, 116, 0.16); + color: var(--accent-strong); +} + +.status-badge[data-state="loading"]::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(110deg, transparent 15%, rgba(255, 255, 255, 0.48) 50%, transparent 85%); + transform: translateX(-130%); + animation: badge-sweep 1.8s ease-in-out infinite; +} + +.message-list { + padding: 18px 22px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; + overscroll-behavior: contain; +} + +.empty-state { + margin: clamp(28px, 10vh, 96px) auto 24px; + padding: 28px; + max-width: 540px; + border-radius: 28px; + background: rgba(255, 255, 255, 0.84); + border: 1px dashed var(--line-strong); + text-align: center; +} + +.empty-state h3 { + margin: 0 0 10px; + font-size: 1.3rem; + font-family: var(--font-display); +} + +.empty-state p { + margin: 0; + color: var(--muted); + line-height: 1.7; +} + +.message { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 14px; + align-items: start; +} + +.message.assistant .avatar { + background: linear-gradient(135deg, rgba(15, 157, 116, 0.95), rgba(10, 104, 77, 0.95)); +} + +.message.user .avatar { + background: linear-gradient(135deg, rgba(20, 28, 24, 0.95), rgba(61, 75, 67, 0.9)); +} + +.avatar { + width: 44px; + height: 44px; + border-radius: 16px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.message-body { + padding: 14px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid var(--line); +} + +.message.user .message-body { + background: rgba(250, 250, 248, 0.96); +} + +.message-meta { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.message-role { + font-size: 0.95rem; +} + +.message-time { + color: var(--muted); + font-size: 0.82rem; +} + +.message-text { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.75; +} + +.generation-loading { + margin-top: 14px; + padding: 14px; + border-radius: 24px; + border: 1px solid rgba(15, 157, 116, 0.14); + background: + radial-gradient(circle at top left, rgba(15, 157, 116, 0.1), transparent 36%), + linear-gradient(180deg, rgba(249, 252, 250, 0.98) 0%, rgba(243, 248, 245, 0.96) 100%); +} + +.generation-loading-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.generation-loading-label, +.generation-loading-detail { + margin: 0; +} + +.generation-loading-label { + font-size: 0.96rem; + font-weight: 700; +} + +.generation-loading-detail { + margin-top: 4px; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.6; +} + +.generation-loading-elapsed { + flex: none; + padding: 7px 10px; + border-radius: 999px; + background: rgba(15, 27, 23, 0.06); + color: var(--muted); + font-size: 0.76rem; + font-weight: 600; + white-space: nowrap; +} + +.generation-loading-visual { + position: relative; + margin-top: 14px; + width: min(100%, 360px); + aspect-ratio: 1 / 1; + border-radius: 30px; + overflow: hidden; + border: 1px solid rgba(15, 27, 23, 0.06); + background: + radial-gradient(circle at 28% 22%, rgba(15, 157, 116, 0.12), transparent 26%), + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 246, 243, 0.94) 100%); +} + +.generation-loading-shimmer, +.generation-loading-orb, +.generation-loading-grid { + position: absolute; + inset: 0; +} + +.generation-loading-shimmer { + background: linear-gradient(120deg, transparent 18%, rgba(255, 255, 255, 0.86) 50%, transparent 82%); + transform: translateX(-120%); + animation: loading-shimmer 2.4s ease-in-out infinite; +} + +.generation-loading-orb { + inset: auto; + width: 58%; + height: 58%; + top: 22%; + left: 21%; + border-radius: 50%; + background: radial-gradient(circle, rgba(15, 157, 116, 0.22) 0%, rgba(15, 157, 116, 0.06) 42%, transparent 74%); + filter: blur(18px); + animation: loading-orb 3.2s ease-in-out infinite; +} + +.generation-loading-grid { + inset: 14%; + background-image: radial-gradient(circle, rgba(89, 98, 92, 0.32) 0 2.4px, transparent 2.6px); + background-size: 26px 26px; + mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 0.9) 26%, rgba(0, 0, 0, 0) 74%); + -webkit-mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 0.9) 26%, rgba(0, 0, 0, 0) 74%); + animation: loading-grid 4.8s ease-in-out infinite; +} + +.generation-loading-steps { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.generation-loading-pill { + padding: 7px 11px; + border-radius: 999px; + background: rgba(15, 27, 23, 0.05); + color: var(--muted); + font-size: 0.76rem; + font-weight: 600; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.generation-loading-pill.is-done, +.generation-loading-pill.is-active { + color: var(--accent-strong); +} + +.generation-loading-pill.is-done { + background: rgba(15, 157, 116, 0.1); +} + +.generation-loading-pill.is-active { + background: rgba(15, 157, 116, 0.16); + transform: translateY(-1px); +} + +.message-images { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 240px)); + justify-content: flex-start; + gap: 10px; + margin-top: 14px; +} + +.image-card { + overflow: hidden; + border-radius: 18px; + background: #fff; + border: 1px solid var(--line); + width: 100%; + max-width: 240px; +} + +.image-preview-button { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + padding: 0; + border: none; + display: block; + overflow: hidden; + background: linear-gradient(180deg, rgba(15, 157, 116, 0.05), rgba(15, 27, 23, 0.04)); +} + +.image-card img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: linear-gradient(180deg, rgba(15, 157, 116, 0.04), rgba(15, 27, 23, 0.03)); +} + +.image-preview-hint { + position: absolute; + right: 10px; + bottom: 10px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(21, 27, 23, 0.72); + color: #fff; + font-size: 0.78rem; + font-weight: 600; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.image-preview-button:hover .image-preview-hint, +.image-preview-button:focus-visible .image-preview-hint { + opacity: 1; + transform: translateY(0); +} + +.image-preview-button:focus-visible { + outline: 3px solid rgba(15, 157, 116, 0.22); + outline-offset: -3px; +} + +.image-card-footer { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 10px 10px 12px; +} + +.image-label { + color: var(--muted); + font-size: 0.82rem; +} + +.download-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 8px 12px; + background: rgba(15, 157, 116, 0.12); + color: var(--accent-strong); + font-weight: 700; + text-decoration: none; +} + +.message-extra { + margin-top: 12px; + color: var(--muted); + font-size: 0.85rem; + line-height: 1.7; +} + +.composer-panel { + padding: 14px 22px 20px; + border-top: 1px solid var(--line); + background: rgba(248, 248, 245, 0.85); + box-shadow: 0 -8px 18px rgba(31, 43, 36, 0.04); +} + +.prompt-preview { + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.78); + overflow: hidden; +} + +.prompt-preview summary { + padding: 12px 14px; + cursor: pointer; + font-weight: 700; + user-select: none; +} + +.prompt-preview pre { + margin: 0; + padding: 0 14px 14px; + white-space: pre-wrap; + word-break: break-word; + color: var(--muted); + line-height: 1.75; +} + +.composer-form textarea { + width: 100%; + min-height: 88px; + max-height: 200px; + overflow: auto; + resize: vertical; +} + +.composer-actions { + margin-top: 12px; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 50; + display: none; + place-items: center; + padding: 24px; +} + +.lightbox.is-open { + display: grid; +} + +.lightbox-backdrop { + position: absolute; + inset: 0; + background: rgba(10, 14, 12, 0.74); + backdrop-filter: blur(8px); +} + +.lightbox-dialog { + position: relative; + z-index: 1; + width: min(1120px, calc(100vw - 48px)); + max-height: calc(100vh - 48px); + padding: 18px; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(18, 22, 20, 0.94); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.35); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 14px; +} + +.lightbox-close { + justify-self: end; + width: 42px; + height: 42px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 1.5rem; + line-height: 1; +} + +.lightbox-image-wrap { + min-height: 0; + display: grid; + place-items: center; +} + +.lightbox-image-wrap img { + max-width: 100%; + max-height: calc(100vh - 190px); + display: block; + object-fit: contain; + border-radius: 18px; +} + +.lightbox-image-wrap img:not([src]), +.lightbox-image-wrap img[src=""] { + display: none; +} + +.lightbox-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.lightbox-label { + color: rgba(255, 255, 255, 0.86); + font-size: 0.92rem; +} + +body.is-lightbox-open { + overflow: hidden; +} + +.is-loading .message-body { + position: relative; +} + +.is-loading .message-text::after { + content: " ···"; + animation: dots 1.2s infinite; +} + +.primary-button.is-loading::before { + content: ""; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.34); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes dots { + 0% { + content: " ·"; + } + 33% { + content: " ··"; + } + 66% { + content: " ···"; + } + 100% { + content: " ·"; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes badge-sweep { + 100% { + transform: translateX(130%); + } +} + +@keyframes loading-shimmer { + 100% { + transform: translateX(120%); + } +} + +@keyframes loading-orb { + 0%, + 100% { + transform: scale(0.92); + opacity: 0.65; + } + 50% { + transform: scale(1.06); + opacity: 1; + } +} + +@keyframes loading-grid { + 0%, + 100% { + transform: scale(0.97) translateY(0); + opacity: 0.72; + } + 50% { + transform: scale(1.03) translateY(-4px); + opacity: 1; + } +} + +@media (max-width: 1100px) { + .page-shell { + grid-template-columns: 304px minmax(0, 1fr); + } +} + +@media (max-width: 960px) { + body { + min-height: 100vh; + min-height: 100dvh; + height: auto; + overflow: auto; + } + + .page-shell { + grid-template-columns: 1fr; + height: auto; + min-height: 100vh; + min-height: 100dvh; + overflow: visible; + } + + .control-panel, + .chat-panel { + height: auto; + min-height: 70vh; + } +} + +@media (max-width: 640px) { + .page-shell { + padding: 12px; + gap: 12px; + } + + .control-panel, + .chat-panel { + border-radius: 24px; + } + + .control-panel { + padding: 20px 16px; + height: auto; + overflow: visible; + } + + .chat-header, + .message-list, + .composer-panel { + padding-left: 16px; + padding-right: 16px; + } + + .preset-wrap, + .grid-fields, + .header-actions { + grid-template-columns: 1fr; + display: grid; + } + + .message { + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + } + + .message-images { + grid-template-columns: 1fr; + } + + .image-card { + max-width: none; + } + + .generation-loading-top { + flex-direction: column; + } + + .generation-loading-elapsed { + align-self: flex-start; + } + + .generation-loading-visual { + width: 100%; + } + + .lightbox { + padding: 12px; + } + + .lightbox-dialog { + width: min(100vw - 24px, 100%); + max-height: calc(100vh - 24px); + padding: 14px; + } + + .lightbox-footer { + flex-direction: column; + align-items: stretch; + } + + .avatar { + width: 36px; + height: 36px; + border-radius: 12px; + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..aa317f2 --- /dev/null +++ b/server.js @@ -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}`); +}); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..1b07962 --- /dev/null +++ b/start.bat @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +node server.js diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..1df7811 --- /dev/null +++ b/start.sh @@ -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