any 用不了 opus?参考 [纯前端html] any的claude一直429用不了?那就拿来生图吧,理论上支持/v1/responses的站点都可以用
对这个 html 进行了增强,无需输入 url,无需输入模型,支持并行生成(抽卡当然要并行了),经过测试一个 key 只能同时生成一张,所以并行数取决于 key 数量
图片不应该超过1M,不然会失败
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 图片生成工具</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
background: #0b1020;
color: #e5e7eb;
}
.wrap { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.card {
display: grid; grid-template-columns: 1fr 380px; gap: 16px;
background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 20px;
}
@media (max-width: 860px) { .card { grid-template-columns: 1fr; } }
/* ====== 顶部 Tab ====== */
.top-tabs { display: flex; gap: 0; margin-bottom: 16px; border-radius: 10px; overflow: hidden; border: 1px solid #374151; }
.top-tab {
flex: 1; text-align: center; padding: 12px; cursor: pointer; font-size: 15px; font-weight: 600;
background: #0f172a; color: #94a3b8; transition: .15s; user-select: none;
}
.top-tab.active { background: #2563eb; color: #fff; }
.top-tab:first-child { border-right: 1px solid #374151; }
.tab-page { display: none; }
.tab-page.active { display: block; }
h1 { margin: 0 0 4px; font-size: 22px; grid-column: 1 / -1; }
.sub { color: #94a3b8; font-size: 13px; margin-bottom: 0; grid-column: 1 / -1; }
label { font-size: 13px; color: #cbd5e1; margin-bottom: 4px; display: block; }
input, textarea {
width: 100%; box-sizing: border-box;
border: 1px solid #374151; background: #0f172a; color: #e2e8f0;
border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit;
}
textarea { min-height: 80px; resize: vertical; }
button {
border: 0; background: #2563eb; color: #fff;
padding: 10px 18px; border-radius: 8px; font-size: 14px; cursor: pointer;
}
button:disabled { opacity: .5; cursor: not-allowed; }
button.secondary { background: #374151; }
/* ====== 左栏 ====== */
.main-panel { min-width: 0; }
.setting-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.setting-row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 12px; }
@media (max-width: 700px) { .setting-row3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 500px) { .setting-row, .setting-row3 { grid-template-columns: 1fr; } }
/* model 下拉 */
.combo { position: relative; }
.combo-panel {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 50;
background: #0f172a; border: 1px solid #334155; border-radius: 8px;
max-height: 220px; overflow-y: auto; display: none;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
}
.combo-panel.open { display: block; }
.combo-item {
padding: 8px 12px; font-size: 12px; cursor: pointer; color: #e2e8f0;
font-family: ui-monospace,SFMono-Regular,Menlo,monospace;
}
.combo-item:hover, .combo-item.active { background: #1e293b; }
.combo-empty { padding: 10px 12px; font-size: 11px; color: #64748b; }
.pwd-wrap { position: relative; }
.pwd-wrap input, .pwd-wrap textarea { padding-right: 40px; }
.key-input { min-height: 42px; height: 42px; resize: vertical; line-height: 1.4; }
.key-input.masked { -webkit-text-security: disc; }
.pwd-toggle {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
background: none; border: 0; color: #6b7280; cursor: pointer;
font-size: 16px; padding: 4px; line-height: 1;
}
.pwd-toggle:hover { color: #e5e7eb; }
/* ====== 多图选择器 ====== */
.img-picker { margin-bottom: 12px; }
.thumb-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-start; }
.thumb-item {
width: 80px; height: 80px; border-radius: 8px; overflow: hidden;
border: 1px solid #374151; cursor: pointer; position: relative;
flex-shrink: 0; background: #0a0f18;
}
.thumb-item img { width: 100%; height: 100%; object-fit: cover; }
.thumb-item .thumb-remove {
position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
border-radius: 50%; background: rgba(0,0,0,.75); color: #e5e7eb;
border: 0; cursor: pointer; font-size: 12px; line-height: 18px;
text-align: center; padding: 0; display: none;
}
.thumb-item:hover .thumb-remove { display: block; }
.thumb-item .thumb-remove:hover { background: rgba(185,28,28,.9); }
.thumb-add {
width: 80px; height: 80px; border-radius: 8px;
border: 2px dashed #374151; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #64748b; font-size: 28px; background: #0f172a; transition: .15s;
}
.thumb-add:hover { border-color: #2563eb; color: #3b82f6; background: #111d32; }
/* ====== 结果区域 ====== */
.result-area {
display: none; margin-top: 14px; text-align: center;
animation: fadeInUp .4s ease;
}
.result-area.active { display: block; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.result-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;
}
.result-card {
background: #0f172a; border: 1px solid #1f2937; border-radius: 10px;
padding: 10px; animation: fadeInUp .4s ease;
}
.result-card img {
width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 8px;
border: 1px solid #374151; cursor: zoom-in; background: #0a0f18;
transition: transform .15s;
}
.result-card img:hover { transform: scale(1.02); }
.result-info {
margin-top: 6px; font-size: 12px; color: #6b7280;
display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;
}
.actions { margin-top: 10px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.status-bar {
margin-top: 12px; padding: 8px 12px; border-radius: 8px; font-size: 13px;
background: #0f172a; border: 1px solid #1f2937;
display: none; white-space: pre-wrap; word-break: break-word;
}
.status-bar.info { display: block; color: #93c5fd; border-color: #1e3a5f; background: #0c1929; }
.status-bar.done { display: block; color: #bbf7d0; border-color: #166534; background: #052016; }
.status-bar.err { display: block; color: #fecdd3; border-color: #9f1239; background: #20060d; }
/* ====== 右栏:实时面板 ====== */
.side-panel {
display: flex; flex-direction: column; gap: 8px; min-height: 520px;
border-left: 1px solid #1f2937; padding-left: 16px;
}
@media (max-width: 860px) {
.side-panel { border-left: 0; padding-left: 0; border-top: 1px solid #1f2937; padding-top: 14px; min-height: 0; }
}
.side-panel-header {
font-size: 12px; color: #64748b; text-transform: uppercase;
letter-spacing: .08em; display: flex; align-items: center; gap: 8px;
}
.side-panel-header::after {
content: ''; flex: 1; height: 1px; background: #1f2937;
}
/* loading mini */
.loading-mini {
display: none; align-items: center; gap: 10px; padding: 8px 12px;
border-radius: 8px; background: #0c1929; border: 1px solid #1e3a5f;
}
.loading-mini.active { display: flex; }
.loading-mini .spinner-sm {
width: 22px; height: 22px; flex-shrink: 0;
border: 2px solid #1e3a5f; border-top-color: #3b82f6;
border-radius: 50%; animation: spin .8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-mini .stats-row {
font-size: 11px; color: #64748b; display: flex; gap: 14px; flex-wrap: wrap;
}
/* 事件日志 */
.event-log {
display: none; flex: 1;
border: 1px solid #1a2236; border-radius: 8px; background: #0a0f18;
overflow-y: auto; font-size: 12px; min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.event-log.active { display: block; }
.event-log .line {
padding: 2px 10px; border-bottom: 1px solid #0c1320;
display: flex; gap: 6px; align-items: flex-start;
}
.event-log .line:last-child { border-bottom: 0; }
.event-log .ts { color: #4b5563; flex-shrink: 0; min-width: 54px; font-size: 11px; }
.event-log .tag { flex-shrink: 0; min-width: 44px; font-weight: 600; font-size: 11px; }
.event-log .tag.event-tag { color: #f59e0b; }
.event-log .tag.data-tag { color: #3b82f6; }
.event-log .tag.text-tag { color: #6b7280; }
.event-log .tag.done-tag { color: #22c55e; }
.event-log .msg { color: #9ca3af; word-break: break-all; flex: 1; }
/* 文本流 */
.text-stream {
display: none; padding: 8px 10px; border-radius: 8px;
font-size: 12px; background: #0a0f18; border: 1px solid #1a2236;
color: #9ca3af; overflow-y: auto; white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
max-height: 180px;
}
.text-stream.active { display: block; }
/* ====== 全屏预览遮罩 ====== */
.preview-overlay {
display: none; position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,.92); cursor: zoom-out;
align-items: center; justify-content: center;
}
.preview-overlay.open { display: flex; }
.preview-overlay img {
max-width: 90vw; max-height: 90vh; object-fit: contain;
border-radius: 6px; user-select: none;
transition: transform .1s ease; cursor: grab;
}
.preview-overlay img.dragging { cursor: grabbing; transition: none; }
.preview-close {
position: absolute; top: 16px; right: 24px;
font-size: 32px; color: #9ca3af; cursor: pointer;
line-height: 1; z-index: 1; user-select: none;
}
.preview-close:hover { color: #fff; }
.preview-hint {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
font-size: 12px; color: #6b7280; pointer-events: none;
}
/* ====== 图片展馆 ====== */
.gallery-card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 20px; }
.gallery-card h2 { margin: 0 0 4px; font-size: 16px; color: #e5e7eb; }
.gallery-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px;
margin-top: 12px;
}
.gallery-item {
background: #0f172a; border: 1px solid #1f2937; border-radius: 10px;
overflow: hidden; display: flex; flex-direction: column;
animation: fadeInUp .3s ease;
}
.gallery-item .thumb-wrap {
position: relative; overflow: hidden;
background: #0a0f18; cursor: pointer;
}
.gallery-item .thumb-wrap img {
width: 100%; height: auto; display: block; transition: transform .2s;
aspect-ratio: 1; object-fit: cover;
}
.gallery-item .thumb-wrap:hover img { transform: scale(1.05); }
.gallery-item .thumb-wrap .mode-badge {
position: absolute; top: 6px; right: 6px;
font-size: 10px; padding: 2px 8px; border-radius: 999px;
background: rgba(0,0,0,.7); color: #e5e7eb;
}
.gallery-item .ref-row {
display: flex; gap: 4px; padding: 4px 6px 0; flex-wrap: wrap;
}
.gallery-item .ref-thumb {
width: 48px; height: 48px; border-radius: 4px; overflow: hidden;
border: 1px solid #374151; cursor: pointer; flex-shrink: 0;
background: #0a0f18;
}
.gallery-item .ref-thumb img { width: 100%; height: 100%; object-fit: cover; }
.gallery-item .ref-thumb:hover { border-color: #3b82f6; }
.gallery-item .info {
padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; flex: 1;
}
.gallery-item .info .prompt-text {
font-size: 12px; color: #9ca3af; line-height: 1.5;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden; word-break: break-word; flex: 1;
}
.gallery-item .info .prompt-row { display: flex; gap: 6px; align-items: flex-start; }
.gallery-item .info .copy-prompt {
background: none; border: 0; color: #4b5563; cursor: pointer;
font-size: 13px; padding: 1px 4px; line-height: 1; flex-shrink: 0;
}
.gallery-item .info .copy-prompt:hover { color: #e5e7eb; }
.gallery-item .info .meta {
font-size: 11px; color: #4b5563; display: flex; justify-content: space-between; align-items: center;
}
.gallery-item .info .del-btn {
background: none; border: 0; color: #6b7280; cursor: pointer;
font-size: 16px; padding: 2px 6px; line-height: 1;
}
.gallery-item .info .del-btn:hover { color: #ef4444; }
.gallery-empty { text-align: center; color: #4b5563; font-size: 13px; padding: 24px 0; }
</style>
</head>
<body>
<div class="wrap">
<!-- ============ 顶部 Tab ============ -->
<div class="top-tabs">
<div class="top-tab active" data-page="draw">画图</div>
<div class="top-tab" data-page="gallery">图片展馆 <span id="topGalleryBadge" style="font-size:12px;opacity:.7;"></span></div>
</div>
<!-- ============ 画图页 ============ -->
<div id="tabDraw" class="tab-page active">
<div class="card">
<h1>AI 图片生成</h1>
<div class="sub">纯前端版:浏览器直连上游 API,支持文生图 & 图生图</div>
<!-- ============ 左栏:输入 + 结果 ============ -->
<div class="main-panel">
<div class="setting-row3">
<div>
<label for="baseUrl">Base URL</label>
<input id="baseUrl" placeholder="https://anyrouter.top" autocomplete="off" />
</div>
<div>
<label for="apiKey">API Keys(每行一个,自动轮询)</label>
<div class="pwd-wrap">
<textarea id="apiKey" class="key-input masked" placeholder="sk-... sk-..." autocomplete="off" spellcheck="false"></textarea>
<button id="pwdToggle" class="pwd-toggle" title="显示/隐藏">👁</button>
</div>
</div>
</div>
<!-- 参考图片选择器(多图) -->
<div class="img-picker">
<label>参考图片(可选,上传后自动切换为图生图模式,支持多张)</label>
<div class="thumb-row" id="thumbRow">
<input id="imageFile" type="file" accept="image/*" multiple style="display:none;" />
<div id="thumbAdd" class="thumb-add" title="添加参考图片">+</div>
</div>
</div>
<div>
<label for="prompt">图片描述(提示词)</label>
<textarea id="prompt" placeholder="例如:一只在雨中奔跑的柴犬,皮克斯动画风格"></textarea>
</div>
<div style="margin-top:12px;">
<label for="batchCount">生成张数</label>
<input id="batchCount" type="number" min="1" step="1" value="1" />
</div>
<button id="genBtn" style="margin-top:10px; width:100%;">生成图片</button>
<div id="resultArea" class="result-area">
<div class="result-info"><span id="resultStats"></span></div>
<div class="actions">
<button id="downloadAllBtn">下载本次全部</button>
</div>
<div id="resultGrid" class="result-grid"></div>
</div>
<div id="statusBar" class="status-bar"></div>
</div>
<!-- ============ 右栏:实时事件流 ============ -->
<div class="side-panel">
<div class="side-panel-header">实时事件流</div>
<div id="loadingMini" class="loading-mini">
<div class="spinner-sm"></div>
<div class="stats-row">
<span id="statEvents">📡 事件: 0</span>
<span id="statTextLen">📝 文本: 0 字</span>
<span id="statElapsed">⏱ 0s</span>
</div>
</div>
<div id="eventLog" class="event-log"></div>
<div id="textStream" class="text-stream"></div>
</div>
</div>
</div>
<!-- ============ 全屏预览遮罩(全局,跨 Tab) ============ -->
<div id="previewOverlay" class="preview-overlay">
<span class="preview-close">×</span>
<img id="previewImg" alt="预览" draggable="false" />
<span class="preview-hint">滚轮缩放 · ESC 关闭</span>
</div>
<!-- ============ 图片展馆页 ============ -->
<div id="tabGallery" class="tab-page">
<div class="gallery-card">
<h2>生成记录展馆 <span id="galleryCount" style="font-size:13px;color:#64748b;font-weight:400;"></span></h2>
<div id="galleryGrid" class="gallery-grid"></div>
<div id="galleryEmpty" class="gallery-empty" style="display:none;">暂无生成记录,快去生成第一张图片吧</div>
</div>
</div>
</div>
<script>
// ========== DOM 引用 ==========
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
const els = {
topTabs: $$('.top-tab'),
tabDraw: $('#tabDraw'),
tabGallery: $('#tabGallery'),
topGalleryBadge: $('#topGalleryBadge'),
apiKey: $('#apiKey'),
pwdToggle: $('#pwdToggle'),
baseUrl: $('#baseUrl'),
imageFile: $('#imageFile'),
thumbRow: $('#thumbRow'),
thumbAdd: $('#thumbAdd'),
prompt: $('#prompt'),
batchCount: $('#batchCount'),
genBtn: $('#genBtn'),
loadingMini: $('#loadingMini'),
statEvents: $('#statEvents'),
statTextLen: $('#statTextLen'),
statElapsed: $('#statElapsed'),
eventLog: $('#eventLog'),
textStream: $('#textStream'),
statusBar: $('#statusBar'),
resultArea: $('#resultArea'),
resultGrid: $('#resultGrid'),
resultStats: $('#resultStats'),
downloadAllBtn: $('#downloadAllBtn'),
previewOverlay: $('#previewOverlay'),
previewImg: $('#previewImg'),
galleryGrid: $('#galleryGrid'),
galleryCount: $('#galleryCount'),
galleryEmpty: $('#galleryEmpty'),
};
let resultDataUrl = null;
let currentResults = [];
// ========== 多图选择器 ==========
let refImages = []; // { file, dataUrl }
function renderThumbnails() {
// 清除旧缩略图
els.thumbRow.querySelectorAll('.thumb-item').forEach((el) => el.remove());
refImages.forEach((ref, i) => {
const item = document.createElement('div');
item.className = 'thumb-item';
const img = document.createElement('img');
img.src = ref.dataUrl;
img.alt = '参考图 ' + (i + 1);
img.addEventListener('click', (e) => {
e.stopPropagation();
resultDataUrl = ref.dataUrl;
openPreview();
});
item.appendChild(img);
const rm = document.createElement('button');
rm.className = 'thumb-remove';
rm.textContent = '×';
rm.title = '移除';
rm.addEventListener('click', (e) => {
e.stopPropagation();
refImages.splice(i, 1);
renderThumbnails();
});
item.appendChild(rm);
els.thumbRow.insertBefore(item, els.thumbAdd);
});
// 保留 add 按钮事件
}
function addRefFiles(files) {
const valid = Array.from(files).filter((f) => {
if (f.size > 50 * 1024 * 1024) { alert(`${f.name} 超过 50MB,已跳过`); return false; }
return true;
});
valid.forEach((file) => {
const reader = new FileReader();
reader.onload = () => {
refImages.push({ file, dataUrl: reader.result });
renderThumbnails();
};
reader.readAsDataURL(file);
});
els.imageFile.value = '';
}
function clearRefImages() {
refImages = [];
renderThumbnails();
}
function lockInputs() {
els.apiKey.disabled = true;
els.baseUrl.disabled = true;
els.prompt.disabled = true;
els.batchCount.disabled = true;
els.thumbAdd.style.display = 'none';
els.thumbRow.querySelectorAll('.thumb-remove').forEach((b) => { b.style.display = 'none'; });
}
function unlockInputs() {
els.apiKey.disabled = false;
els.baseUrl.disabled = false;
els.prompt.disabled = false;
els.batchCount.disabled = false;
els.thumbAdd.style.display = '';
els.thumbRow.querySelectorAll('.thumb-remove').forEach((b) => { b.style.display = ''; });
}
els.thumbAdd.addEventListener('click', () => els.imageFile.click());
els.imageFile.addEventListener('change', () => {
if (els.imageFile.files.length) addRefFiles(els.imageFile.files);
});
// ========== 顶部 Tab 切换 ==========
els.topTabs.forEach((tab) => {
tab.addEventListener('click', () => {
els.topTabs.forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
const page = tab.dataset.page;
els.tabDraw.classList.toggle('active', page === 'draw');
els.tabGallery.classList.toggle('active', page === 'gallery');
});
});
// ========== localStorage ==========
const SAVED_KEY = 'img_gen_settings';
// 恢复上次成功生成时保存的设置
try {
const saved = localStorage.getItem(SAVED_KEY);
if (saved) {
const s = JSON.parse(saved);
if (s.baseUrl) els.baseUrl.value = s.baseUrl;
if (s.apiKey || s.model) {
delete s.apiKey;
delete s.model;
localStorage.setItem(SAVED_KEY, JSON.stringify(s));
}
if (s.batchCount) els.batchCount.value = s.batchCount;
}
} catch {}
function saveSettings() {
try {
localStorage.setItem(SAVED_KEY, JSON.stringify({
baseUrl: els.baseUrl.value.trim(),
batchCount: els.batchCount.value,
}));
} catch {}
}
const DEFAULT_BASE_URL = 'https://anyrouter.top';
const MODEL_ID = 'gpt-5.3-codex';
function normalizeBaseUrl(input) {
let base = String(input || '').trim() || DEFAULT_BASE_URL;
if (!/^https?:\/\//i.test(base)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
base = base.replace(/\/+$/, '');
// 统一由请求路径拼接 /v1。
if (base.endsWith('/v1')) base = base.slice(0, -3);
return base;
}
function getApiKeys() {
return els.apiKey.value.split(/[\n,,]+/).map((key) => key.trim()).filter(Boolean);
}
function getBatchCount() {
const count = Number.parseInt(els.batchCount.value, 10);
if (!Number.isFinite(count) || count < 1) return 1;
return count;
}
// ========== 图片展馆 (IndexedDB) ==========
const DB_NAME = 'img-gen-gallery';
const DB_VERSION = 1;
let gallery = [];
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains('records')) {
req.result.createObjectStore('records', { keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function loadGallery() {
try {
const db = await openDB();
const tx = db.transaction('records', 'readonly');
const all = await new Promise((resolve, reject) => {
const req = tx.objectStore('records').getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
gallery = all.sort((a, b) => b.id - a.id);
db.close();
} catch { /* gallery 保持空 */ }
}
async function saveRecord(record) {
try {
const db = await openDB();
const tx = db.transaction('records', 'readwrite');
tx.objectStore('records').put(record);
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch {}
}
async function deleteRecord(id) {
try {
const db = await openDB();
const tx = db.transaction('records', 'readwrite');
tx.objectStore('records').delete(id);
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch {}
}
async function addToGallery(dataUrl, prompt, mode, refDataUrl) {
const id = Date.now() + Math.random();
const record = {
id,
dataUrl,
prompt,
mode,
refDataUrl: refDataUrl || null,
time: new Date().toLocaleString('zh-CN'),
};
gallery.unshift(record);
renderGallery();
saveRecord(record);
}
async function deleteFromGallery(id) {
gallery = gallery.filter((r) => r.id !== id);
renderGallery();
deleteRecord(id);
}
function renderGallery() {
els.galleryGrid.innerHTML = '';
if (gallery.length === 0) {
els.galleryEmpty.style.display = '';
els.galleryCount.textContent = '';
els.topGalleryBadge.textContent = '';
return;
}
els.galleryEmpty.style.display = 'none';
els.galleryCount.textContent = `(${gallery.length} 张)`;
els.topGalleryBadge.textContent = `(${gallery.length})`;
for (const r of gallery) {
const card = document.createElement('div');
card.className = 'gallery-item';
const thumbWrap = document.createElement('div');
thumbWrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.src = r.dataUrl;
img.alt = '生成的图片';
img.loading = 'lazy';
img.addEventListener('click', () => {
resultDataUrl = r.dataUrl;
openPreview();
});
thumbWrap.appendChild(img);
const badge = document.createElement('span');
badge.className = 'mode-badge';
badge.textContent = r.mode === 1 ? '文生图' : '图生图';
thumbWrap.appendChild(badge);
card.appendChild(thumbWrap);
// 参考图行(图生图)
if (r.refDataUrl) {
const refRow = document.createElement('div');
refRow.className = 'ref-row';
const refThumb = document.createElement('div');
refThumb.className = 'ref-thumb';
const refImg = document.createElement('img');
refImg.src = r.refDataUrl;
refImg.alt = '参考图';
refThumb.addEventListener('click', (e) => {
e.stopPropagation();
resultDataUrl = r.refDataUrl;
openPreview();
});
refThumb.appendChild(refImg);
refRow.appendChild(refThumb);
card.appendChild(refRow);
}
// 信息区域
const info = document.createElement('div');
info.className = 'info';
const promptRow = document.createElement('div');
promptRow.className = 'prompt-row';
const promptEl = document.createElement('div');
promptEl.className = 'prompt-text';
promptEl.textContent = r.prompt;
promptEl.title = r.prompt;
promptRow.appendChild(promptEl);
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-prompt';
copyBtn.innerHTML = '📋';
copyBtn.title = '复制提示词';
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(r.prompt).then(() => {
copyBtn.innerHTML = '✅';
setTimeout(() => { copyBtn.innerHTML = '📋'; }, 1200);
});
});
promptRow.appendChild(copyBtn);
info.appendChild(promptRow);
const meta = document.createElement('div');
meta.className = 'meta';
const timeSpan = document.createElement('span');
timeSpan.textContent = r.time;
meta.appendChild(timeSpan);
const delBtn = document.createElement('button');
delBtn.className = 'del-btn';
delBtn.innerHTML = '🗑';
delBtn.title = '删除';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteFromGallery(r.id);
});
meta.appendChild(delBtn);
info.appendChild(meta);
card.appendChild(info);
els.galleryGrid.appendChild(card);
}
}
// 页面加载时从 IndexedDB 恢复
loadGallery().then(() => renderGallery());
// ========== 密码显隐切换 ==========
els.pwdToggle.addEventListener('click', () => {
const masked = els.apiKey.classList.toggle('masked');
els.pwdToggle.textContent = masked ? '\u{1f441}' : '\u{1f441}\u{200d}\u{1f5e8}';
});
// ========== 事件日志 ==========
let eventCount = 0;
function appendEvent(type, msg) {
eventCount++;
els.statEvents.textContent = `📡 事件: ${eventCount}`;
els.eventLog.classList.add('active');
const now = new Date();
const ts = now.toTimeString().slice(0, 8);
const tagClass = type === 'event' ? 'event-tag' : type === 'data' ? 'data-tag' : type === 'text' ? 'text-tag' : 'done-tag';
const tagLabel = type === 'event' ? 'EVENT' : type === 'data' ? 'DATA' : type === 'text' ? 'TEXT' : 'DONE';
const line = document.createElement('div');
line.className = 'line';
line.innerHTML = `<span class="ts">${ts}</span><span class="tag ${tagClass}">${tagLabel}</span><span class="msg">${escapeHtml(msg)}</span>`;
els.eventLog.appendChild(line);
els.eventLog.scrollTop = els.eventLog.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// ========== 计时器 ==========
let startTime = 0;
let timerInterval = null;
function startTimer() {
startTime = Date.now();
els.statElapsed.textContent = '⏱ 0s';
timerInterval = setInterval(() => {
const s = Math.floor((Date.now() - startTime) / 1000);
els.statElapsed.textContent = `⏱ ${s}s`;
}, 500);
}
function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
const s = ((Date.now() - startTime) / 1000).toFixed(1);
els.statElapsed.textContent = `⏱ ${s}s`;
}
// ========== 递归搜索 base64 ==========
function extractBase64AsDataUrl(dataObj) {
if (dataObj == null) return null;
if (Array.isArray(dataObj)) {
for (const item of dataObj) {
const found = extractBase64AsDataUrl(item);
if (found) return found;
}
} else if (typeof dataObj === 'object') {
for (const [key, value] of Object.entries(dataObj)) {
if (key === 'result' && typeof value === 'string' && (value.startsWith('iVBOR') || value.length > 1000)) {
return 'data:image/png;base64,' + value;
}
const found = extractBase64AsDataUrl(value);
if (found) return found;
}
}
return null;
}
// ========== 构建请求体 ==========
async function buildPayload() {
const prompt = els.prompt.value.trim();
const model = MODEL_ID;
if (refImages.length > 0) {
const editPrompt = `请根据以下要求,对我提供的参考图片进行编辑修改,直接生成修改后的新图片。要求:${prompt}`;
const content = refImages.map((ref) => ({ type: 'input_image', image_url: ref.dataUrl }));
content.push({ type: 'input_text', text: editPrompt });
return {
model,
input: [{ role: 'user', content }],
tools: [{ type: 'image_generation', output_format: 'png' }],
stream: true,
};
} else {
return {
model,
input: [
{ role: 'system', content: '你是一个图片生成助手。用户要求你生成图片时,你必须调用 image_generation 工具来生成图片,不要用文字描述图片内容。直接生成图片,不要多说任何话。' },
{ role: 'user', content: `请生成以下描述的图片:${prompt}` },
],
tools: [{ type: 'image_generation', output_format: 'png' }],
stream: true,
};
}
}
// ========== UI 重置 ==========
function resetUI() {
els.loadingMini.classList.remove('active');
els.eventLog.classList.remove('active');
els.eventLog.innerHTML = '';
els.textStream.classList.remove('active');
els.textStream.textContent = '';
els.statusBar.className = 'status-bar';
els.statusBar.style.display = '';
els.statusBar.textContent = '';
els.resultArea.classList.remove('active');
els.resultGrid.innerHTML = '';
els.resultStats.textContent = '';
els.genBtn.disabled = false;
els.genBtn.textContent = '生成图片';
resultDataUrl = null;
currentResults = [];
eventCount = 0;
stopTimer();
}
function dataUrlToBlob(dataUrl) {
const b64 = dataUrl.split(',')[1];
const byteChars = atob(b64);
const bytes = new Uint8Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i);
return new Blob([bytes], { type: 'image/png' });
}
function formatSize(bytes) {
const sizeKB = (bytes / 1024).toFixed(1);
return sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) + ' MB' : sizeKB + ' KB';
}
function addCurrentResult(dataUrl, blob, prompt, index, total, sizeStr) {
const result = { dataUrl, blob, prompt, index, sizeStr };
currentResults.push(result);
resultDataUrl = dataUrl;
els.resultArea.classList.add('active');
els.resultStats.textContent = `本次已生成 ${currentResults.length}/${total} 张`;
const card = document.createElement('div');
card.className = 'result-card';
const img = document.createElement('img');
img.src = dataUrl;
img.alt = `生成的图片 ${index}`;
img.addEventListener('click', () => {
resultDataUrl = dataUrl;
openPreview();
});
card.appendChild(img);
const info = document.createElement('div');
info.className = 'result-info';
info.textContent = `第 ${index} 张 · ${sizeStr}`;
card.appendChild(info);
const actions = document.createElement('div');
actions.className = 'actions';
const downloadBtn = document.createElement('button');
downloadBtn.textContent = '下载';
downloadBtn.addEventListener('click', () => downloadBlob(blob, downloadFilename(index)));
actions.appendChild(downloadBtn);
const copyBtn = document.createElement('button');
copyBtn.className = 'secondary';
copyBtn.textContent = '复制';
copyBtn.addEventListener('click', () => copyBlob(blob, copyBtn));
actions.appendChild(copyBtn);
card.appendChild(actions);
els.resultGrid.appendChild(card);
}
async function requestOneImage({ baseUrl, apiKey, payload, prompt, index, total }) {
const url = baseUrl + '/v1/responses';
const headers = {
'Authorization': 'Bearer ' + apiKey,
'chatgpt-account-id': '',
'version': '0.122.0',
'originator': 'codex_cli_rs',
'session_id': 'browser-' + Date.now() + '-' + index,
'accept': 'text/event-stream',
'Content-Type': 'application/json',
};
let collectedText = [];
appendEvent('event', `开始第 ${index}/${total} 张:POST /v1/responses`);
const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload) });
if (!response.ok) {
let errText = '';
try { errText = await response.text(); } catch {}
throw new Error(`第 ${index} 张失败:HTTP ${response.status}${errText ? ' — ' + errText.slice(0, 300) : ''}`);
}
appendEvent('event', `第 ${index} 张 HTTP ${response.status} — 连接成功`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('event: ')) {
const eventName = trimmed.slice(7);
if (eventName !== 'response.output_text.delta') appendEvent('event', eventName);
continue;
}
if (!trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6);
if (dataStr === '[DONE]' || dataStr === '') continue;
try {
const data = JSON.parse(dataStr);
const dataUrl = extractBase64AsDataUrl(data);
if (dataUrl) {
const blob = dataUrlToBlob(dataUrl);
const sizeStr = formatSize(blob.size);
addCurrentResult(dataUrl, blob, prompt, index, total, sizeStr);
appendEvent('done', `第 ${index} 张图片已抓取,大小: ${sizeStr}`);
const mode = refImages.length > 0 ? 2 : 1;
const refUrl = refImages.length > 0 ? refImages[0].dataUrl : null;
addToGallery(dataUrl, prompt, mode, refUrl);
return true;
}
const delta = data.delta;
if (typeof delta === 'string' && delta) {
collectedText.push(delta);
els.textStream.classList.add('active');
els.textStream.textContent += delta;
els.textStream.scrollTop = els.textStream.scrollHeight;
els.statTextLen.textContent = `📝 文本: ${collectedText.join('').length} 字`;
if (collectedText.length % 5 === 1) appendEvent('text', `第 ${index} 张已接收 ${collectedText.length} 个 delta`);
}
if (!data.delta && eventCount % 3 === 0) {
const keys = Object.keys(data).slice(0, 3).join(', ');
appendEvent('data', keys ? `{ ${keys}... }` : `[${typeof data}]`);
}
} catch {}
}
}
if (collectedText.length) {
appendEvent('done', `第 ${index} 张只返回文本,未生成图片`);
} else {
appendEvent('done', `第 ${index} 张流结束,未收到图片`);
}
return false;
}
// ========== 核心:发送请求 ==========
async function generate() {
const apiKeys = getApiKeys();
if (!apiKeys.length) { alert('请填写至少一个 API Key'); return; }
let baseUrl;
try { baseUrl = normalizeBaseUrl(els.baseUrl.value); }
catch (e) { alert(e.message); return; }
const prompt = els.prompt.value.trim();
if (!prompt) { alert('请填写提示词'); return; }
const total = getBatchCount();
els.batchCount.value = total;
saveSettings();
let payload;
try { payload = await buildPayload(); }
catch (e) { alert(e.message); return; }
resetUI();
lockInputs();
els.genBtn.disabled = true;
els.genBtn.textContent = total > 1 ? `生成中 0/${total}…` : '生成中…';
els.loadingMini.classList.add('active');
els.statEvents.textContent = '📡 事件: 0';
els.statTextLen.textContent = '📝 文本: 0 字';
startTimer();
let successCount = 0;
let finishedCount = 0;
let nextIndex = 1;
try {
const workers = apiKeys.map(async (apiKey, keyIndex) => {
while (nextIndex <= total) {
const index = nextIndex++;
try {
appendEvent('event', `第 ${index}/${total} 张分配给 key ${keyIndex + 1}`);
const ok = await requestOneImage({ baseUrl, apiKey, payload, prompt, index, total });
if (ok) successCount++;
} catch (err) {
appendEvent('event', `第 ${index} 张(key ${keyIndex + 1})失败:${err.message || err}`);
} finally {
finishedCount++;
els.genBtn.textContent = total > 1 ? `生成中 ${finishedCount}/${total}…` : '生成中…';
}
}
});
await Promise.all(workers);
} finally {
stopTimer();
els.loadingMini.classList.remove('active');
unlockInputs();
els.genBtn.disabled = false;
els.genBtn.textContent = '重新生成';
if (successCount > 0) clearRefImages();
}
if (successCount > 0) {
els.statusBar.className = successCount === total ? 'status-bar done' : 'status-bar info';
els.statusBar.textContent = `完成:成功生成 ${successCount}/${total} 张,已加入图片展馆。`;
els.resultStats.textContent = `本次生成 ${successCount}/${total} 张 · 📡 ${eventCount} 个事件 · ⏱ ${((Date.now() - startTime) / 1000).toFixed(1)}s`;
} else {
els.statusBar.className = 'status-bar err';
els.statusBar.textContent = '没有生成出图片,请查看右侧事件流里的错误信息。';
}
}
// ========== 下载 ==========
function downloadFilename(index = 1) {
const d = new Date();
const pad = (n) => String(n).padStart(2, '0');
return `img-${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}-${String(index).padStart(2, '0')}.png`;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadAll() {
currentResults.forEach((result) => downloadBlob(result.blob, downloadFilename(result.index)));
}
// ========== 复制 ==========
async function copyBlob(blob, button) {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
const orig = button.textContent;
button.textContent = '已复制!';
setTimeout(() => { button.textContent = orig; }, 1500);
} catch {
alert('复制失败,浏览器可能不支持。请用下载替代。');
}
}
// ========== 图片预览(点击放大 / ESC 关闭 / 滚轮缩放 / 拖拽平移) ==========
let previewScale = 1;
let panX = 0, panY = 0;
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let panStartX = 0, panStartY = 0;
function updatePreviewTransform() {
els.previewImg.style.transform = `translate(${panX}px, ${panY}px) scale(${previewScale})`;
}
function openPreview() {
if (!resultDataUrl) return;
previewScale = 1;
panX = 0;
panY = 0;
els.previewImg.src = resultDataUrl;
updatePreviewTransform();
els.previewOverlay.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closePreview() {
els.previewOverlay.classList.remove('open');
document.body.style.overflow = '';
}
els.previewOverlay.addEventListener('click', (e) => {
if (e.target === els.previewOverlay || e.target.classList.contains('preview-close')) {
closePreview();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && els.previewOverlay.classList.contains('open')) {
closePreview();
}
});
// 滚轮缩放(以鼠标位置为锚点)
els.previewOverlay.addEventListener('wheel', (e) => {
if (!els.previewOverlay.classList.contains('open')) return;
e.preventDefault();
const rect = els.previewImg.getBoundingClientRect();
const fx = (e.clientX - rect.left) / rect.width;
const fy = (e.clientY - rect.top) / rect.height;
const oldScale = previewScale;
previewScale = Math.max(0.2, Math.min(5, previewScale + (e.deltaY > 0 ? -0.1 : 0.1)));
const ratio = previewScale / oldScale;
const newLeft = e.clientX - fx * rect.width * ratio;
const newTop = e.clientY - fy * rect.height * ratio;
panX += newLeft - rect.left;
panY += newTop - rect.top;
updatePreviewTransform();
});
// 拖拽平移
els.previewImg.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
els.previewImg.classList.add('dragging');
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
updatePreviewTransform();
});
window.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
els.previewImg.classList.remove('dragging');
});
// ========== 事件绑定 ==========
els.genBtn.addEventListener('click', generate);
els.downloadAllBtn.addEventListener('click', downloadAll);
els.prompt.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') generate();
});
</script>
</body>
</html>
4 个帖子 - 3 位参与者