feat(project): init
This commit is contained in:
422
public/app.js
Normal file
422
public/app.js
Normal file
@@ -0,0 +1,422 @@
|
||||
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("'", "'");
|
||||
}
|
||||
Reference in New Issue
Block a user