feat(project): init

This commit is contained in:
2026-04-27 14:33:50 +08:00
commit 3d74b28f26
12 changed files with 2789 additions and 0 deletions

775
scripts/web_app.py Normal file
View File

@@ -0,0 +1,775 @@
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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())