423 lines
11 KiB
JavaScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|