File size: 5,208 Bytes
cfea436 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { prompts, images } from '../db/schema';
import { getFullSettings } from './settings';
import type { Env, Variables } from '../index';
import { db as globalDb } from '../db';
export const generateApi = new Hono<{ Bindings: Env; Variables: Variables }>();
// 全局 jobs 表(进程内)—— 任务从客户端连接解耦,刷新页面不影响
type JobState = {
jobId: string;
promptId: string;
status: 'running' | 'done' | 'error' | 'cancelled';
size: string;
quality: string;
startedAt: number;
finishedAt?: number;
errorMsg?: string;
imageId?: string;
generationDuration?: number;
abort?: AbortController;
};
const jobs = new Map<string, JobState>();
// 已完成 job 保留 5 分钟供前端拉取,之后清理
const KEEP_DONE_MS = 5 * 60 * 1000;
function gcJobs() {
const now = Date.now();
for (const [k, v] of jobs) {
if (v.status !== 'running' && v.finishedAt && now - v.finishedAt > KEEP_DONE_MS) {
jobs.delete(k);
}
}
}
// 列出当前所有 job(运行中 + 最近完成)
generateApi.get('/jobs', (c) => {
gcJobs();
const list = Array.from(jobs.values()).map(({ abort, ...rest }) => rest);
return c.json(list);
});
// 取消运行中的 job
generateApi.delete('/jobs/:jobId', (c) => {
const j = jobs.get(c.req.param('jobId'));
if (!j) return c.json({ error: 'not_found' }, 404);
if (j.status !== 'running') return c.json({ ok: true, status: j.status });
j.abort?.abort();
j.status = 'cancelled';
j.finishedAt = Date.now();
return c.json({ ok: true });
});
// 启动生成;立即返回 jobId,任务后台跑
generateApi.post('/:promptId', async (c) => {
const db = c.var.db;
const promptId = c.req.param('promptId');
const body = await c.req.json<{ size?: string; quality?: string }>().catch(() => ({} as { size?: string; quality?: string }));
const size = body.size || '1024x1024';
const quality = body.quality || 'hd';
const p = (await db.select().from(prompts).where(eq(prompts.id, promptId))).at(0);
if (!p) return c.json({ error: 'prompt_not_found' }, 404);
const promptText = (p.contentImage || '').trim();
if (!promptText) return c.json({ error: 'no_image_prompt' }, 400);
const s = await getFullSettings(db);
if (!s.openaiApiKey) return c.json({ error: 'api_key_not_set' }, 400);
const jobId = crypto.randomUUID();
const ctrl = new AbortController();
jobs.set(jobId, {
jobId,
promptId,
status: 'running',
size,
quality,
startedAt: Date.now(),
abort: ctrl,
});
// 真后台 —— 不 await,立即返回
runGenerationJob(jobId, promptId, promptText, size, quality, s, ctrl.signal).catch((err) => {
const j = jobs.get(jobId);
if (!j || j.status !== 'running') return;
j.status = err?.name === 'AbortError' ? 'cancelled' : 'error';
j.errorMsg = String(err?.message || err);
j.finishedAt = Date.now();
});
return c.json({ jobId, status: 'running' });
});
async function runGenerationJob(
jobId: string,
promptId: string,
promptText: string,
size: string,
quality: string,
s: any,
signal: AbortSignal
) {
const t0 = Date.now();
const url = (s.openaiBaseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '') + '/images/generations';
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${s.openaiApiKey}`,
},
body: JSON.stringify({
model: s.imageModel || 'gpt-image-2',
prompt: promptText,
size,
quality,
n: 1,
response_format: 'b64_json',
}),
signal,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
const data: any = await res.json();
const item = data?.data?.[0];
let b64: string | null = null;
if (item?.b64_json) b64 = `data:image/png;base64,${item.b64_json}`;
else if (item?.url) {
try {
const r2 = await fetch(item.url, { signal });
if (r2.ok) {
const buf = new Uint8Array(await r2.arrayBuffer());
const mime = r2.headers.get('content-type') || 'image/png';
b64 = `data:${mime};base64,${arrayBufferToBase64(buf)}`;
}
} catch {}
}
if (!b64) throw new Error('响应无 b64_json 也无可下载 url');
const duration = Date.now() - t0;
const imageId = crypto.randomUUID();
await globalDb.insert(images).values({
id: imageId,
promptId,
b64,
model: s.imageModel || 'gpt-image-2',
size,
quality,
generationDuration: duration,
promptUsed: promptText,
createdAt: Date.now(),
});
await globalDb.update(prompts).set({ updatedAt: Date.now() }).where(eq(prompts.id, promptId));
const j = jobs.get(jobId);
if (j) {
j.status = 'done';
j.finishedAt = Date.now();
j.imageId = imageId;
j.generationDuration = duration;
}
}
function arrayBufferToBase64(buf: Uint8Array): string {
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < buf.length; i += chunk) {
binary += String.fromCharCode.apply(null, Array.from(buf.subarray(i, i + chunk)));
}
return btoa(binary);
}
|