Files
image-prompt-studio/public/app.js
2026-04-25 11:26:33 +08:00

423 lines
11 KiB
JavaScript

import {
EXAMPLE_PROMPTS,
GENERATION_DEFAULTS,
PROCESSING_STAGES,
STORAGE_KEY,
buildDownloadStem,
buildPromptStats,
createProcessingSnapshot,
sanitizePrompt,
} from "./prompt-builder.js";
const sessionStorageKey = "image-prompt-studio:session-id:v1";
const elements = {
promptInput: document.querySelector("#promptInput"),
promptStats: document.querySelector("#promptStats"),
examplePromptList: document.querySelector("#examplePromptList"),
clearPromptButton: document.querySelector("#clearPromptButton"),
generateButton: document.querySelector("#generateButton"),
healthLabel: document.querySelector("#healthLabel"),
modelLabel: document.querySelector("#modelLabel"),
processingBadge: document.querySelector("#processingBadge"),
processingLead: document.querySelector("#processingLead"),
processingSteps: document.querySelector("#processingSteps"),
progressFill: document.querySelector("#progressFill"),
requestMeta: document.querySelector("#requestMeta"),
resultStage: document.querySelector("#resultStage"),
errorBanner: document.querySelector("#errorBanner"),
downloadButton: document.querySelector("#downloadButton"),
};
const state = {
prompt: loadPrompt(),
isGenerating: false,
health: {
configured: false,
model: "gpt-image-2",
},
results: [],
error: "",
requestMeta: null,
sessionId: getSessionId(),
processing: {
progress: 0,
activeStageIndex: -1,
lead: "准备就绪,输入 Prompt 后即可开始生成。",
},
processingTimer: null,
startedAt: 0,
};
renderExamplePrompts();
elements.promptInput.value = state.prompt;
updatePromptStats();
render();
wireEvents();
void checkHealth();
function wireEvents() {
elements.promptInput.addEventListener("input", () => {
state.prompt = elements.promptInput.value;
persistPrompt();
updatePromptStats();
render();
});
elements.clearPromptButton.addEventListener("click", () => {
state.prompt = "";
state.results = [];
state.requestMeta = null;
state.error = "";
state.processing = {
progress: 0,
activeStageIndex: -1,
lead: "准备就绪,输入 Prompt 后即可开始生成。",
};
elements.promptInput.value = "";
persistPrompt();
updatePromptStats();
render();
});
elements.generateButton.addEventListener("click", () => {
void generateImages();
});
elements.downloadButton.addEventListener("click", () => {
const result = state.results[0];
if (result) {
downloadResult(result);
}
});
document.body.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
void generateImages();
}
});
elements.examplePromptList.addEventListener("click", (event) => {
const button = event.target.closest("[data-prompt]");
if (!button) {
return;
}
state.prompt = button.dataset.prompt ?? "";
elements.promptInput.value = state.prompt;
persistPrompt();
updatePromptStats();
render();
});
}
async function generateImages() {
if (state.isGenerating) {
return;
}
const prompt = sanitizePrompt(state.prompt);
if (!prompt) {
setError("请输入 Prompt 后再生成图片。");
return;
}
state.prompt = prompt;
elements.promptInput.value = prompt;
persistPrompt();
state.isGenerating = true;
state.error = "";
state.results = [];
state.requestMeta = null;
startProcessingTicker();
render();
try {
const response = await fetch("/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt,
...GENERATION_DEFAULTS,
user: state.sessionId,
}),
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "图片生成失败。");
}
const stem = buildDownloadStem(prompt);
state.results = payload.images.map((image, index) => ({
...image,
previewUrl: `data:${image.mimeType};base64,${image.base64}`,
fileName: `${stem}-${index + 1}.${resolveExtension(image.mimeType)}`,
}));
state.requestMeta = payload.meta;
state.processing = createProcessingSnapshot(
Date.now() - state.startedAt,
true,
);
state.error = "";
} catch (error) {
setError(error.message || "图片生成失败,请稍后重试。");
} finally {
state.isGenerating = false;
stopProcessingTicker();
render();
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
const payload = await response.json();
state.health = payload;
} catch {
state.health = {
configured: false,
model: "gpt-image-2",
};
}
render();
}
function startProcessingTicker() {
stopProcessingTicker();
state.startedAt = Date.now();
state.processing = createProcessingSnapshot(0);
state.processingTimer = window.setInterval(() => {
state.processing = createProcessingSnapshot(Date.now() - state.startedAt);
render();
}, 140);
}
function stopProcessingTicker() {
if (state.processingTimer) {
window.clearInterval(state.processingTimer);
state.processingTimer = null;
}
}
function render() {
elements.modelLabel.textContent = state.health.model || "gpt-image-2";
elements.healthLabel.textContent = state.health.configured
? "已连接服务端"
: "缺少 OPENAI_API_KEY";
elements.processingLead.textContent = state.processing.lead;
elements.processingBadge.textContent = state.isGenerating
? "处理中"
: state.results.length > 0
? "已完成"
: "待命";
elements.progressFill.style.width = `${Math.round(state.processing.progress * 100)}%`;
elements.requestMeta.textContent = buildRequestMeta();
elements.errorBanner.hidden = !state.error;
elements.errorBanner.textContent = state.error;
elements.generateButton.disabled = state.isGenerating;
elements.generateButton.textContent = state.isGenerating
? "生成中..."
: "生成图片";
elements.downloadButton.disabled = state.results.length === 0;
renderProcessingSteps();
renderResultStage();
}
function renderExamplePrompts() {
elements.examplePromptList.innerHTML = EXAMPLE_PROMPTS.map(
(prompt) => `
<button class="example-chip" type="button" data-prompt="${escapeHtml(prompt)}">
${escapeHtml(truncate(prompt, 42))}
</button>
`,
).join("");
}
function renderProcessingSteps() {
elements.processingSteps.innerHTML = PROCESSING_STAGES.map((stage, index) => {
const status = resolveStageStatus(index);
return `
<li class="processing-step processing-step-${status}">
<div class="step-dot"></div>
<div>
<strong>${escapeHtml(stage.title)}</strong>
<p>${escapeHtml(stage.detail)}</p>
</div>
</li>
`;
}).join("");
}
function renderResultStage() {
if (state.isGenerating) {
elements.resultStage.className = "result-stage loading-state";
elements.resultStage.innerHTML = `
<div class="result-skeleton">
<div class="skeleton-art"></div>
<div class="skeleton-line skeleton-line-wide"></div>
<div class="skeleton-line"></div>
</div>
`;
return;
}
if (state.results.length === 0) {
elements.resultStage.className = "result-stage empty-state";
elements.resultStage.innerHTML = `
<div class="empty-illustration"></div>
<h4>这里会显示最新生成的图片</h4>
<p>当前只保留最简流程:输入 Prompt、点击生成、等待完成、下载图片。</p>
`;
return;
}
const result = state.results[0];
const revisedPromptMarkup = result.revisedPrompt
? `
<div class="result-copy">
<span class="result-caption">模型调整后的 Prompt</span>
<p>${escapeHtml(result.revisedPrompt)}</p>
</div>
`
: "";
elements.resultStage.className = "result-stage";
elements.resultStage.innerHTML = `
<div class="image-frame">
<img src="${result.previewUrl}" alt="${escapeHtml(result.fileName)}" />
</div>
<div class="result-copy">
<span class="result-caption">你的 Prompt</span>
<p>${escapeHtml(state.prompt)}</p>
</div>
${revisedPromptMarkup}
`;
}
function resolveStageStatus(index) {
if (!state.isGenerating && state.results.length > 0) {
return "done";
}
if (!state.isGenerating) {
return "idle";
}
if (index < state.processing.activeStageIndex) {
return "done";
}
if (index === state.processing.activeStageIndex) {
return "active";
}
return "idle";
}
function buildRequestMeta() {
if (state.requestMeta) {
return `${state.requestMeta.model} · ${state.requestMeta.size} · ${state.requestMeta.outputFormat.toUpperCase()} · ${state.requestMeta.durationMs} ms`;
}
if (state.isGenerating) {
return "复杂图片生成可能需要几十秒,请耐心等待当前步骤完成。";
}
return "生成完成后会在这里显示耗时和输出信息。";
}
function updatePromptStats() {
elements.promptStats.textContent = buildPromptStats(state.prompt);
}
function persistPrompt() {
localStorage.setItem(STORAGE_KEY, state.prompt);
}
function loadPrompt() {
return String(localStorage.getItem(STORAGE_KEY) ?? "");
}
function getSessionId() {
const existing = localStorage.getItem(sessionStorageKey);
if (existing) {
return existing;
}
const sessionId = `browser-${crypto.randomUUID()}`;
localStorage.setItem(sessionStorageKey, sessionId);
return sessionId;
}
function setError(message) {
state.error = message;
stopProcessingTicker();
state.processing = {
progress: 0,
activeStageIndex: -1,
lead: "本次生成未完成,请调整 Prompt 后重试。",
};
render();
}
function downloadResult(result) {
const blob = base64ToBlob(result.base64, result.mimeType);
const blobUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = blobUrl;
anchor.download = result.fileName;
anchor.click();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
}
function base64ToBlob(base64, mimeType) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}
function resolveExtension(mimeType) {
if (mimeType === "image/jpeg") {
return "jpg";
}
if (mimeType === "image/webp") {
return "webp";
}
return "png";
}
function truncate(value, maxLength) {
const text = String(value ?? "");
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength - 1)}`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}