776 lines
32 KiB
Python
776 lines
32 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import html
|
||
import io
|
||
import json
|
||
import mimetypes
|
||
import sys
|
||
import threading
|
||
import urllib.parse
|
||
import webbrowser
|
||
import zipfile
|
||
from datetime import datetime
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
ROOT_DIR = SCRIPT_DIR.parent
|
||
MAX_PREVIEW_STRING = 240
|
||
MAX_HISTORY_ITEMS = 20
|
||
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
import generate_image # noqa: E402
|
||
import runtime_config # noqa: E402
|
||
|
||
|
||
RUNTIME_CONFIG = runtime_config.load_runtime_config(
|
||
execution_root=Path.cwd(),
|
||
require_token=False,
|
||
)
|
||
OUTPUT_DIR = RUNTIME_CONFIG.output_dir
|
||
HISTORY_FILE = OUTPUT_DIR / "history.jsonl"
|
||
|
||
|
||
HTML_PAGE = r"""<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>GPT Image 2 生图工具</title>
|
||
<style>
|
||
:root { color-scheme: dark; font-family: Inter, "Segoe UI", sans-serif; }
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; background: #0f172a; color: #e5e7eb; }
|
||
main { max-width: 1180px; margin: 0 auto; padding: 28px 16px 56px; }
|
||
.panel { background: rgba(15, 23, 42, .82); border: 1px solid #334155; border-radius: 18px; padding: 22px; box-shadow: 0 18px 60px rgba(0, 0, 0, .22); }
|
||
.panel + .panel { margin-top: 18px; }
|
||
h1, h2, h3 { margin: 0; }
|
||
h1 { font-size: 30px; margin-bottom: 8px; }
|
||
h2 { font-size: 22px; margin-bottom: 12px; }
|
||
p { margin: 0; color: #94a3b8; line-height: 1.6; }
|
||
label { display: block; margin: 16px 0 8px; font-weight: 700; }
|
||
textarea, select, input { width: 100%; border: 1px solid #475569; border-radius: 12px; background: #020617; color: #f8fafc; padding: 12px; font: inherit; }
|
||
textarea { min-height: 132px; resize: vertical; }
|
||
.row { display: grid; grid-template-columns: 1fr 170px 120px; gap: 14px; align-items: start; }
|
||
.advanced-grid { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)); gap: 14px; }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
|
||
.chip { margin: 0; padding: 8px 14px; border-radius: 999px; border: 1px solid #475569; background: #111827; color: #cbd5e1; cursor: pointer; }
|
||
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-top: 16px; }
|
||
.button, button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 999px; padding: 13px 22px; font-weight: 800; font-size: 14px; background: linear-gradient(135deg, #38bdf8, #818cf8); color: #020617; cursor: pointer; text-decoration: none; }
|
||
.button.secondary, button.secondary { background: #1e293b; color: #e2e8f0; border: 1px solid #475569; }
|
||
.button.ghost, button.ghost { background: transparent; color: #cbd5e1; border: 1px dashed #475569; }
|
||
button:disabled { opacity: .55; cursor: wait; }
|
||
.hint { margin-top: 8px; font-size: 13px; color: #94a3b8; }
|
||
.status { white-space: pre-wrap; margin-top: 16px; color: #cbd5e1; min-height: 48px; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin-top: 20px; }
|
||
.card, .history-item { border: 1px solid #334155; border-radius: 14px; padding: 12px; background: #020617; }
|
||
.history-item + .history-item { margin-top: 14px; }
|
||
.thumbs { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-top: 14px; }
|
||
img { width: 100%; height: auto; border-radius: 10px; display: block; background: #111827; }
|
||
a { color: #7dd3fc; word-break: break-all; }
|
||
code, pre { background: #111827; border-radius: 12px; }
|
||
code { padding: 2px 6px; }
|
||
pre { margin: 12px 0 0; padding: 14px; overflow: auto; white-space: pre-wrap; word-break: break-word; border: 1px solid #334155; }
|
||
details { margin-top: 16px; border: 1px solid #334155; border-radius: 14px; padding: 12px 14px; background: #020617; }
|
||
summary { cursor: pointer; font-weight: 750; color: #bfdbfe; }
|
||
.section-title { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||
.meta { margin-top: 8px; color: #94a3b8; font-size: 13px; }
|
||
.json-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||
.muted { color: #94a3b8; }
|
||
.empty { color: #94a3b8; padding: 10px 0; }
|
||
.inline-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
|
||
@media (max-width: 900px) {
|
||
.row, .advanced-grid, .json-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<section class="panel">
|
||
<h1>GPT Image 2 生图工具</h1>
|
||
<p>优先读取当前执行目录的 <code>.env</code> 和 <code>config/</code>,再回退到 skill 自带 <code>config/</code>。输入 prompt,可选添加一张或多张参考图,服务端会自动选择纯文本生成或带图编辑,并兼容 apicodex 风格与 OpenAI-compatible 网关。</p>
|
||
<div id="runtimeConfig" class="hint">正在读取运行时配置…</div>
|
||
|
||
<label for="prompt">Prompt</label>
|
||
<textarea id="prompt" placeholder="例如:一只橘猫坐在窗台看夕阳,水彩画风格"></textarea>
|
||
|
||
<div class="chips" id="exampleChips">
|
||
<button type="button" class="chip" data-size="16:9" data-prompt="一只橘猫坐在窗台看夕阳,水彩画风格,电影感,暖色调">示例:橘猫夕阳</button>
|
||
<button type="button" class="chip" data-size="9:16" data-prompt="中国古风少女站在竹林中,轻雾,插画风,高清细节">示例:古风竖图</button>
|
||
<button type="button" class="chip" data-size="1:1" data-prompt="把参考图改成水彩插画风格,保留主体构图">示例:参考图改风格</button>
|
||
<button type="button" class="chip" data-size="4:3" data-prompt="把参考图融合成一张复古海报,统一色调与光影">示例:多参考图海报</button>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div>
|
||
<label for="refs">参考图 URL / data URI(每行一个,可选)</label>
|
||
<textarea id="refs" placeholder="https://example.com/photo.jpg"></textarea>
|
||
</div>
|
||
<div>
|
||
<label for="size">尺寸</label>
|
||
<input id="size" list="sizeOptions" value="16:9" placeholder="例如 2:3 或 1024x1536" />
|
||
<datalist id="sizeOptions">
|
||
<option value="1:1">1:1 正方形</option>
|
||
<option value="16:9">16:9 横屏</option>
|
||
<option value="9:16">9:16 竖屏</option>
|
||
<option value="4:3">4:3 横图</option>
|
||
<option value="3:4">3:4 竖图</option>
|
||
<option value="3:2">3:2 横图</option>
|
||
<option value="2:3">2:3 竖图</option>
|
||
<option value="5:4">5:4 横图</option>
|
||
<option value="4:5">4:5 竖图</option>
|
||
<option value="2:1">2:1 宽屏</option>
|
||
<option value="1:2">1:2 长竖图</option>
|
||
<option value="21:9">21:9 超宽屏</option>
|
||
<option value="9:21">9:21 超长竖图</option>
|
||
<option value="1024x1024">1024x1024</option>
|
||
<option value="1024x1536">1024x1536</option>
|
||
<option value="1536x1024">1536x1024</option>
|
||
<option value="864x1536">864x1536</option>
|
||
<option value="1536x864">1536x864</option>
|
||
<option value="2048x3584">2048x3584</option>
|
||
</datalist>
|
||
</div>
|
||
<div>
|
||
<label for="count">数量</label>
|
||
<input id="count" type="number" min="1" max="4" value="1" />
|
||
</div>
|
||
</div>
|
||
|
||
<label for="files">本地参考图(可多选,可选)</label>
|
||
<input id="files" type="file" accept="image/*" multiple />
|
||
<div class="hint">本地图片会在浏览器中转换为 data URI 后提交给本机服务。尺寸既可填比例,如 <code>2:3</code>,也可填像素,如 <code>1024x1536</code>。</div>
|
||
|
||
<details>
|
||
<summary>高级参数:超时和异步轮询</summary>
|
||
<div class="advanced-grid">
|
||
<div>
|
||
<label for="timeout">请求超时(秒)</label>
|
||
<input id="timeout" type="number" min="30" max="600" value="300" />
|
||
</div>
|
||
<div>
|
||
<label for="initialPollDelay">首次轮询等待(秒)</label>
|
||
<input id="initialPollDelay" type="number" min="0" max="60" value="10" />
|
||
</div>
|
||
<div>
|
||
<label for="pollInterval">轮询间隔(秒)</label>
|
||
<input id="pollInterval" type="number" min="1" max="30" value="5" />
|
||
</div>
|
||
<div>
|
||
<label for="maxWait">最长等待(秒)</label>
|
||
<input id="maxWait" type="number" min="30" max="1800" value="300" />
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<div class="toolbar">
|
||
<button id="generate">开始生成</button>
|
||
<a id="downloadAllCurrent" class="button secondary" href="#" hidden>下载本次全部图片</a>
|
||
<button id="refreshHistory" type="button" class="secondary">刷新历史记录</button>
|
||
</div>
|
||
|
||
<div id="status" class="status"></div>
|
||
<div id="result" class="grid"></div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="section-title">
|
||
<h2>JSON 详情面板</h2>
|
||
<div class="inline-links">
|
||
<a id="rawResponseLink" href="#" target="_blank" hidden>打开原始响应 JSON</a>
|
||
<a id="rawTaskLink" href="#" target="_blank" hidden>打开任务 JSON</a>
|
||
</div>
|
||
</div>
|
||
<p id="jsonTitle" class="muted">生成完成后,可在这里查看接口返回的 JSON 预览。</p>
|
||
<div class="json-grid">
|
||
<div>
|
||
<h3>响应 JSON 预览</h3>
|
||
<pre id="responsePreview">暂无</pre>
|
||
</div>
|
||
<div>
|
||
<h3>任务 JSON 预览</h3>
|
||
<pre id="taskPreview">暂无</pre>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="section-title">
|
||
<h2>历史生成记录</h2>
|
||
<span class="muted">显示最近 20 条</span>
|
||
</div>
|
||
<div id="historyList" class="empty">暂无历史记录</div>
|
||
</section>
|
||
</main>
|
||
<script>
|
||
const state = { historyItems: [] };
|
||
|
||
function escapeHtml(value) {
|
||
return String(value || "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function fileToDataURL(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result);
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function toJsonText(value) {
|
||
if (!value) return "暂无";
|
||
return JSON.stringify(value, null, 2);
|
||
}
|
||
|
||
function buildDownloadAllUrl(images) {
|
||
const names = (images || []).map(item => item.name).filter(Boolean);
|
||
return names.length ? `/api/download-all?names=${encodeURIComponent(names.join(","))}` : "#";
|
||
}
|
||
|
||
function updateDownloadButton(images) {
|
||
const button = document.querySelector("#downloadAllCurrent");
|
||
const url = buildDownloadAllUrl(images);
|
||
if (url === "#") {
|
||
button.hidden = true;
|
||
button.removeAttribute("href");
|
||
return;
|
||
}
|
||
button.hidden = false;
|
||
button.href = url;
|
||
}
|
||
|
||
function showJsonDetails(title, responsePreview, taskPreview, responseFile, taskFile) {
|
||
document.querySelector("#jsonTitle").textContent = title || "JSON 详情";
|
||
document.querySelector("#responsePreview").textContent = toJsonText(responsePreview);
|
||
document.querySelector("#taskPreview").textContent = toJsonText(taskPreview);
|
||
|
||
const responseLink = document.querySelector("#rawResponseLink");
|
||
if (responseFile && responseFile.url) {
|
||
responseLink.hidden = false;
|
||
responseLink.href = responseFile.url;
|
||
} else {
|
||
responseLink.hidden = true;
|
||
responseLink.removeAttribute("href");
|
||
}
|
||
|
||
const taskLink = document.querySelector("#rawTaskLink");
|
||
if (taskFile && taskFile.url) {
|
||
taskLink.hidden = false;
|
||
taskLink.href = taskFile.url;
|
||
} else {
|
||
taskLink.hidden = true;
|
||
taskLink.removeAttribute("href");
|
||
}
|
||
}
|
||
|
||
function renderRuntimeConfig(data) {
|
||
const el = document.querySelector("#runtimeConfig");
|
||
if (!data) {
|
||
el.textContent = "未获取到运行时配置。";
|
||
return;
|
||
}
|
||
|
||
const tokenLabel = data.token_configured
|
||
? `已配置(${data.token_source || "unknown"})`
|
||
: "未配置";
|
||
const envLabel = data.env_file_exists ? data.env_file : `${data.env_file}(不存在)`;
|
||
el.textContent =
|
||
`执行目录:${data.execution_root} | .env:${envLabel} | token:${tokenLabel} | base URL:${data.base_url} | 请求风格:${data.request_style || "unknown"} | 输出目录:${data.output_dir}`;
|
||
}
|
||
|
||
function renderResult(data) {
|
||
const result = document.querySelector("#result");
|
||
const images = data.images || [];
|
||
if (!images.length) {
|
||
result.innerHTML = '<div class="empty">本次没有返回可展示的图片。</div>';
|
||
} else {
|
||
result.innerHTML = images.map(image => `
|
||
<div class="card">
|
||
<a href="${image.url}" target="_blank"><img src="${image.url}" alt="generated image"></a>
|
||
<div class="meta">${escapeHtml(image.name)}</div>
|
||
<div class="inline-links">
|
||
<a href="${image.url}" download>下载图片</a>
|
||
<a href="${image.url}" target="_blank">新窗口打开</a>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
updateDownloadButton(images);
|
||
const sizeLabel = data.api_size && data.api_size !== data.size ? `${data.size} -> ${data.api_size}` : data.size;
|
||
showJsonDetails(
|
||
`本次生成:${data.request_mode} / ${data.request_type || "unknown"} / ${data.mode} / ${sizeLabel}`,
|
||
data.response_preview,
|
||
data.task_response_preview,
|
||
data.response_file,
|
||
data.task_response_file,
|
||
);
|
||
}
|
||
|
||
function renderHistory(items) {
|
||
state.historyItems = items || [];
|
||
const list = document.querySelector("#historyList");
|
||
if (!state.historyItems.length) {
|
||
list.className = "empty";
|
||
list.textContent = "暂无历史记录";
|
||
return;
|
||
}
|
||
|
||
list.className = "";
|
||
list.innerHTML = state.historyItems.map((item, index) => {
|
||
const thumbs = (item.images || []).map(image => `
|
||
<a href="${image.url}" target="_blank">
|
||
<img src="${image.url}" alt="history image">
|
||
</a>
|
||
`).join("");
|
||
const downloadLink = buildDownloadAllUrl(item.images || []);
|
||
return `
|
||
<div class="history-item">
|
||
<h3>${escapeHtml(item.prompt || "未命名 prompt")}</h3>
|
||
<div class="meta">${escapeHtml(item.created_at || "")} · 输入 ${escapeHtml(item.request_mode || "")} · 接口 ${escapeHtml(item.request_type || "")} · 返回模式 ${escapeHtml(item.result_mode || "")} · 尺寸 ${escapeHtml(item.api_size && item.api_size !== item.size ? `${item.size} -> ${item.api_size}` : item.size || "")}</div>
|
||
<div class="meta">模型 ${escapeHtml(item.model || "")} · Base URL ${escapeHtml(item.base_url || "")} · 风格 ${escapeHtml(item.request_style || "")}</div>
|
||
<div class="meta">数量 ${escapeHtml(item.n || 0)} · 参考图 ${escapeHtml(item.ref_count || 0)} 张</div>
|
||
<div class="toolbar">
|
||
<button type="button" class="secondary history-json" data-index="${index}">查看 JSON</button>
|
||
${downloadLink === "#" ? "" : `<a class="button secondary" href="${downloadLink}">下载该次全部图片</a>`}
|
||
</div>
|
||
${thumbs ? `<div class="thumbs">${thumbs}</div>` : '<div class="empty">该记录暂无可展示图片</div>'}
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
list.querySelectorAll(".history-json").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
const index = Number(button.dataset.index);
|
||
const item = state.historyItems[index];
|
||
showJsonDetails(
|
||
`历史记录:${item.prompt || "未命名 prompt"}`,
|
||
item.response_preview,
|
||
item.task_response_preview,
|
||
item.response_file,
|
||
item.task_response_file,
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function refreshHistory() {
|
||
const response = await fetch("/api/history");
|
||
const data = await response.json();
|
||
if (!response.ok || !data.ok) throw new Error(data.error || "读取历史失败");
|
||
renderHistory(data.items || []);
|
||
}
|
||
|
||
async function refreshRuntimeConfig() {
|
||
const response = await fetch("/api/runtime-config");
|
||
const data = await response.json();
|
||
if (!response.ok || !data.ok) throw new Error(data.error || "读取运行时配置失败");
|
||
renderRuntimeConfig(data.config || null);
|
||
}
|
||
|
||
async function generate() {
|
||
const button = document.querySelector("#generate");
|
||
const status = document.querySelector("#status");
|
||
button.disabled = true;
|
||
document.querySelector("#result").innerHTML = "";
|
||
status.textContent = "正在提交请求,请等待……";
|
||
|
||
try {
|
||
const prompt = document.querySelector("#prompt").value.trim();
|
||
const textRefs = document.querySelector("#refs").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean);
|
||
const fileRefs = await Promise.all([...document.querySelector("#files").files].map(fileToDataURL));
|
||
const allRefs = [...textRefs, ...fileRefs];
|
||
|
||
if (!prompt) throw new Error("请输入 prompt");
|
||
|
||
const response = await fetch("/api/generate", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
prompt,
|
||
size: document.querySelector("#size").value,
|
||
n: Number(document.querySelector("#count").value || 1),
|
||
image_refs: allRefs,
|
||
timeout: Number(document.querySelector("#timeout").value || 300),
|
||
initial_poll_delay: Number(document.querySelector("#initialPollDelay").value || 10),
|
||
poll_interval: Number(document.querySelector("#pollInterval").value || 5),
|
||
max_wait: Number(document.querySelector("#maxWait").value || 300)
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (!response.ok || !data.ok) throw new Error(data.error || "生成失败");
|
||
|
||
const sizeLabel = data.api_size && data.api_size !== data.size ? `${data.size} -> ${data.api_size}` : data.size;
|
||
status.textContent = `完成:${data.mode}\n输入:${data.request_mode}\n接口:${data.request_type || "unknown"}\n模型:${data.model || "unknown"}\nBase URL:${data.base_url || "unknown"}\n风格:${data.request_style || "unknown"}\n尺寸:${sizeLabel}\nJSON:${data.response_file ? data.response_file.name : '无'}`;
|
||
if (data.task_response_file) {
|
||
status.textContent += `\nTask JSON:${data.task_response_file.name}`;
|
||
}
|
||
if (data.revised_prompts && data.revised_prompts.length) {
|
||
status.textContent += `\nRevised prompt:${data.revised_prompts.join("\n")}`;
|
||
}
|
||
|
||
renderResult(data);
|
||
await refreshHistory();
|
||
} catch (error) {
|
||
updateDownloadButton([]);
|
||
status.textContent = `错误:${error.message}`;
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
document.querySelector("#generate").addEventListener("click", generate);
|
||
document.querySelector("#refreshHistory").addEventListener("click", async () => {
|
||
try {
|
||
await refreshHistory();
|
||
} catch (error) {
|
||
document.querySelector("#status").textContent = `错误:${error.message}`;
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll("#exampleChips .chip").forEach(button => {
|
||
button.addEventListener("click", () => {
|
||
document.querySelector("#prompt").value = button.dataset.prompt || "";
|
||
document.querySelector("#size").value = button.dataset.size || "16:9";
|
||
});
|
||
});
|
||
|
||
refreshHistory().catch(error => {
|
||
document.querySelector("#status").textContent = `历史记录加载失败:${error.message}`;
|
||
});
|
||
refreshRuntimeConfig().catch(error => {
|
||
document.querySelector("#runtimeConfig").textContent = `运行时配置加载失败:${error.message}`;
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def json_bytes(data: dict[str, Any]) -> bytes:
|
||
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||
|
||
|
||
def ensure_output_dir() -> None:
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def safe_output_path(name: str) -> Path:
|
||
candidate = OUTPUT_DIR / Path(name).name
|
||
resolved = candidate.resolve()
|
||
output_root = OUTPUT_DIR.resolve()
|
||
if resolved.parent != output_root:
|
||
raise ValueError("invalid file name")
|
||
return resolved
|
||
|
||
|
||
def build_file_meta(path_value: str | Path | None) -> dict[str, str] | None:
|
||
if not path_value:
|
||
return None
|
||
path = Path(path_value)
|
||
name = path.name
|
||
return {
|
||
"name": name,
|
||
"path": str(path),
|
||
"url": f"/outputs/{urllib.parse.quote(name)}",
|
||
}
|
||
|
||
|
||
def sanitize_for_preview(value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
return {key: sanitize_for_preview(item) for key, item in value.items()}
|
||
if isinstance(value, list):
|
||
if len(value) > 20:
|
||
trimmed = value[:20]
|
||
return [sanitize_for_preview(item) for item in trimmed] + [f"... truncated {len(value) - 20} more items"]
|
||
return [sanitize_for_preview(item) for item in value]
|
||
if isinstance(value, str) and len(value) > MAX_PREVIEW_STRING:
|
||
return f"{value[:MAX_PREVIEW_STRING]}... <truncated {len(value) - MAX_PREVIEW_STRING} chars>"
|
||
return value
|
||
|
||
|
||
def load_json_preview(path_value: str | Path | None) -> Any | None:
|
||
if not path_value:
|
||
return None
|
||
path = Path(path_value)
|
||
if not path.exists():
|
||
return None
|
||
try:
|
||
return sanitize_for_preview(json.loads(path.read_text(encoding="utf-8")))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def append_history(item: dict[str, Any]) -> None:
|
||
ensure_output_dir()
|
||
with HISTORY_FILE.open("a", encoding="utf-8") as handle:
|
||
handle.write(json.dumps(item, ensure_ascii=False) + "\n")
|
||
|
||
|
||
def load_history(limit: int = MAX_HISTORY_ITEMS) -> list[dict[str, Any]]:
|
||
if not HISTORY_FILE.exists():
|
||
return []
|
||
items: list[dict[str, Any]] = []
|
||
for line in HISTORY_FILE.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
items.append(json.loads(line))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
return list(reversed(items[-limit:]))
|
||
|
||
|
||
def build_history_entry(
|
||
request_mode: str,
|
||
prompt: str,
|
||
size: str,
|
||
n: int,
|
||
ref_count: int,
|
||
images: list[dict[str, str]],
|
||
result: dict[str, Any],
|
||
response_preview: Any | None,
|
||
task_response_preview: Any | None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"request_mode": request_mode,
|
||
"request_type": result.get("request_type"),
|
||
"result_mode": result.get("mode"),
|
||
"base_url": result.get("base_url"),
|
||
"model": result.get("model"),
|
||
"request_style": result.get("request_style"),
|
||
"prompt": prompt,
|
||
"size": result.get("requested_size", size),
|
||
"api_size": result.get("api_size"),
|
||
"n": n,
|
||
"ref_count": ref_count,
|
||
"images": images,
|
||
"response_file": build_file_meta(result.get("response_path")),
|
||
"task_response_file": build_file_meta(result.get("task_response_path")),
|
||
"response_preview": response_preview,
|
||
"task_response_preview": task_response_preview,
|
||
}
|
||
|
||
|
||
class WebHandler(BaseHTTPRequestHandler):
|
||
server_version = "GPTImage2Web/2.0"
|
||
|
||
def log_message(self, format: str, *args: Any) -> None:
|
||
print(f"{self.address_string()} - {format % args}")
|
||
|
||
def send_body(self, status: int, body: bytes, content_type: str, extra_headers: dict[str, str] | None = None) -> None:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
if extra_headers:
|
||
for key, value in extra_headers.items():
|
||
self.send_header(key, value)
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def send_json(self, status: int, data: dict[str, Any]) -> None:
|
||
self.send_body(status, json_bytes(data), "application/json; charset=utf-8")
|
||
|
||
def do_GET(self) -> None:
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
if parsed.path in {"/", "/index.html"}:
|
||
self.send_body(200, HTML_PAGE.encode("utf-8"), "text/html; charset=utf-8")
|
||
return
|
||
if parsed.path == "/health":
|
||
self.send_json(200, {"ok": True})
|
||
return
|
||
if parsed.path == "/api/runtime-config":
|
||
self.handle_runtime_config()
|
||
return
|
||
if parsed.path == "/api/history":
|
||
self.handle_history(parsed.query)
|
||
return
|
||
if parsed.path == "/api/download-all":
|
||
self.handle_download_all(parsed.query)
|
||
return
|
||
if parsed.path.startswith("/outputs/"):
|
||
self.serve_output(parsed.path)
|
||
return
|
||
self.send_json(404, {"ok": False, "error": "not found"})
|
||
|
||
def do_POST(self) -> None:
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
if parsed.path != "/api/generate":
|
||
self.send_json(404, {"ok": False, "error": "not found"})
|
||
return
|
||
|
||
try:
|
||
length = int(self.headers.get("Content-Length", "0"))
|
||
if length > 80 * 1024 * 1024:
|
||
self.send_json(413, {"ok": False, "error": "request too large"})
|
||
return
|
||
|
||
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||
prompt = str(payload.get("prompt", "")).strip()
|
||
size = str(payload.get("size", "16:9"))
|
||
n = int(payload.get("n", 1))
|
||
image_refs = [str(item) for item in payload.get("image_refs", []) if str(item).strip()]
|
||
request_mode = "prompt+images" if image_refs else "prompt-only"
|
||
|
||
if not prompt:
|
||
raise ValueError("请输入 prompt")
|
||
|
||
result = generate_image.generate_image(
|
||
prompt=prompt,
|
||
size=size,
|
||
n=n,
|
||
image_refs=image_refs,
|
||
output_dir=str(OUTPUT_DIR),
|
||
timeout=int(payload.get("timeout", 300)),
|
||
initial_poll_delay=float(payload.get("initial_poll_delay", 10)),
|
||
poll_interval=float(payload.get("poll_interval", 5)),
|
||
max_wait=float(payload.get("max_wait", 300)),
|
||
)
|
||
except (ValueError, generate_image.ImageGenerationError) as error:
|
||
self.send_json(400, {"ok": False, "error": str(error)})
|
||
return
|
||
except Exception as error:
|
||
self.send_json(500, {"ok": False, "error": str(error)})
|
||
return
|
||
|
||
images = [build_file_meta(path) for path in result.get("images", [])]
|
||
images = [item for item in images if item is not None]
|
||
response_preview = load_json_preview(result.get("response_path"))
|
||
task_response_preview = load_json_preview(result.get("task_response_path"))
|
||
history_entry = build_history_entry(
|
||
request_mode=request_mode,
|
||
prompt=prompt,
|
||
size=size,
|
||
n=n,
|
||
ref_count=len(image_refs),
|
||
images=images,
|
||
result=result,
|
||
response_preview=response_preview,
|
||
task_response_preview=task_response_preview,
|
||
)
|
||
append_history(history_entry)
|
||
|
||
self.send_json(200, {
|
||
"ok": True,
|
||
"request_mode": request_mode,
|
||
"request_type": result.get("request_type"),
|
||
"mode": result.get("mode"),
|
||
"base_url": result.get("base_url"),
|
||
"model": result.get("model"),
|
||
"request_style": result.get("request_style"),
|
||
"size": result.get("requested_size", size),
|
||
"api_size": result.get("api_size"),
|
||
"n": n,
|
||
"images": images,
|
||
"response_file": build_file_meta(result.get("response_path")),
|
||
"task_response_file": build_file_meta(result.get("task_response_path")),
|
||
"response_preview": response_preview,
|
||
"task_response_preview": task_response_preview,
|
||
"revised_prompts": result.get("revised_prompts", []),
|
||
})
|
||
|
||
def handle_history(self, query: str) -> None:
|
||
params = urllib.parse.parse_qs(query)
|
||
try:
|
||
limit = int(params.get("limit", [str(MAX_HISTORY_ITEMS)])[0])
|
||
except ValueError:
|
||
limit = MAX_HISTORY_ITEMS
|
||
limit = max(1, min(limit, 100))
|
||
self.send_json(200, {"ok": True, "items": load_history(limit)})
|
||
|
||
def handle_runtime_config(self) -> None:
|
||
self.send_json(200, {"ok": True, "config": runtime_config.runtime_config_summary(RUNTIME_CONFIG)})
|
||
|
||
def handle_download_all(self, query: str) -> None:
|
||
params = urllib.parse.parse_qs(query)
|
||
raw_names = params.get("names", [""])[0]
|
||
names = [Path(name).name for name in raw_names.split(",") if name.strip()]
|
||
if not names:
|
||
self.send_json(400, {"ok": False, "error": "no image names provided"})
|
||
return
|
||
|
||
try:
|
||
paths = [safe_output_path(name) for name in names]
|
||
except ValueError as error:
|
||
self.send_json(400, {"ok": False, "error": str(error)})
|
||
return
|
||
|
||
existing = [path for path in paths if path.exists() and path.is_file()]
|
||
if not existing:
|
||
self.send_json(404, {"ok": False, "error": "no output files found"})
|
||
return
|
||
|
||
buffer = io.BytesIO()
|
||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||
for path in existing:
|
||
archive.write(path, arcname=path.name)
|
||
body = buffer.getvalue()
|
||
filename = f"gpt-image-2-bundle-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
|
||
self.send_body(
|
||
200,
|
||
body,
|
||
"application/zip",
|
||
{
|
||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||
},
|
||
)
|
||
|
||
def serve_output(self, request_path: str) -> None:
|
||
name = Path(urllib.parse.unquote(request_path.removeprefix("/outputs/"))).name
|
||
try:
|
||
resolved = safe_output_path(name)
|
||
if not resolved.exists() or not resolved.is_file():
|
||
self.send_json(404, {"ok": False, "error": "output not found"})
|
||
return
|
||
content_type = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream"
|
||
self.send_body(200, resolved.read_bytes(), content_type)
|
||
except Exception as error:
|
||
self.send_json(500, {"ok": False, "error": html.escape(str(error))})
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Start the GPT Image 2 local web UI")
|
||
parser.add_argument("--host", default="127.0.0.1")
|
||
parser.add_argument("--port", default=7862, type=int)
|
||
parser.add_argument("--open", action="store_true", help="Open browser after startup")
|
||
args = parser.parse_args()
|
||
|
||
ensure_output_dir()
|
||
server = ThreadingHTTPServer((args.host, args.port), WebHandler)
|
||
url = f"http://{args.host}:{args.port}/"
|
||
print(f"GPT Image 2 web tool is running: {url}")
|
||
print(f"Execution root: {RUNTIME_CONFIG.execution_root}")
|
||
print(f"Project config dir: {RUNTIME_CONFIG.project_config_dir}")
|
||
print(f"Skill fallback config dir: {RUNTIME_CONFIG.skill_config_dir}")
|
||
print(f"Base URL: {RUNTIME_CONFIG.base_url} ({RUNTIME_CONFIG.base_url_source})")
|
||
print(f"Output dir: {OUTPUT_DIR}")
|
||
print("Press Ctrl+C to stop.")
|
||
if args.open:
|
||
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nStopped.")
|
||
finally:
|
||
server.server_close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|