Files
memind/image-generation.mjs
T
John 6d99d762da Add image-designer skill, align billing to DeepSeek ×1, and enrich Plaza demo.
Introduce AI image generation with chat shortcut and agent API, improve MindSpace chat-to-page save resolution, and seed Plaza covers with production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:29:19 -07:00

341 lines
12 KiB
JavaScript

import fs from 'node:fs/promises';
import path from 'node:path';
import { Agent, fetch as undiciFetch } from 'undici';
import {
decryptSecret,
normalizeApiUrl,
resolveChatCompletionsUrl,
} from './llm-providers.mjs';
import { buildPublicUrl, resolvePublishKey } from './user-publish.mjs';
import { isPathInsideUserWorkspace, resolveUserWorkspaceRoot } from './user-space.mjs';
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
});
const ALLOWED_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const ALLOWED_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', '512x512', '768x768']);
const ALLOWED_STYLES = new Set(['vivid', 'natural']);
const ALLOWED_QUALITIES = new Set(['standard', 'hd']);
export function loadImageGenConfig(env = process.env) {
return {
enabled: env.H5_IMAGE_GEN_ENABLED !== '0',
apiUrl: String(env.H5_IMAGE_GEN_API_URL ?? '').trim(),
apiKey: String(env.H5_IMAGE_GEN_API_KEY ?? '').trim(),
model: String(env.H5_IMAGE_GEN_MODEL ?? 'dall-e-3').trim() || 'dall-e-3',
defaultSize: String(env.H5_IMAGE_GEN_DEFAULT_SIZE ?? '1024x1024').trim() || '1024x1024',
defaultStyle: String(env.H5_IMAGE_GEN_DEFAULT_STYLE ?? 'vivid').trim() || 'vivid',
defaultQuality: String(env.H5_IMAGE_GEN_DEFAULT_QUALITY ?? 'standard').trim() || 'standard',
billCents: Number(env.H5_IMAGE_GEN_BILL_CENTS ?? 30),
};
}
export function resolveImagesGenerationsUrl(apiUrl) {
const configured = normalizeApiUrl(apiUrl);
if (!configured) return '';
if (configured.endsWith('/images/generations')) return configured;
if (configured.endsWith('/chat/completions')) {
return configured.replace(/\/chat\/completions$/, '/images/generations');
}
if (configured.endsWith('/v1')) return `${configured}/images/generations`;
return `${configured}/v1/images/generations`;
}
function resolveEncryptionKey(explicitKey) {
return (
explicitKey ??
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
process.env.TKMIND_SERVER__SECRET_KEY ??
'local-dev-secret'
);
}
function slugifyFilename(value) {
return String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
export function normalizeImageOutputPath(rawPath, prompt) {
const cleaned = String(rawPath ?? '')
.normalize('NFKC')
.trim()
.replace(/^\.?\/+/, '')
.replace(/\\/g, '/');
const fallback = `assets/${slugifyFilename(prompt) || 'generated-image'}.png`;
const relative = cleaned || fallback;
if (
!relative ||
relative.includes('..') ||
relative.startsWith('/') ||
relative.includes('\0')
) {
throw Object.assign(new Error('输出路径无效'), { code: 'invalid_output_path' });
}
const ext = path.extname(relative).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
throw Object.assign(new Error('仅支持 png / jpg / webp 输出'), { code: 'invalid_output_path' });
}
return relative;
}
export function resolveWorkspaceOutputPath(workspaceRoot, relativePath) {
const absolute = path.resolve(workspaceRoot, relativePath);
if (!isPathInsideUserWorkspace(workspaceRoot, absolute)) {
throw Object.assign(new Error('输出路径必须位于当前用户工作区内'), { code: 'invalid_output_path' });
}
return absolute;
}
export function composeDesignerPrompt({ prompt, styleNotes, aspectRatio, brandColors, subject }) {
const parts = [
'Professional commercial visual design, high-end art direction, polished composition, clean lighting, production-ready quality.',
];
if (subject) parts.push(`Subject: ${subject}.`);
if (styleNotes) parts.push(`Style direction: ${styleNotes}.`);
if (aspectRatio) parts.push(`Aspect ratio intent: ${aspectRatio}.`);
if (brandColors) parts.push(`Brand palette: ${brandColors}.`);
parts.push(`Creative brief: ${String(prompt ?? '').trim()}`);
parts.push('Avoid text overlays, watermarks, logos, distorted anatomy, blurry details, or cluttered backgrounds unless explicitly requested.');
return parts.join(' ');
}
function parseImagePayload(data) {
const item = data?.data?.[0];
if (!item) return null;
if (typeof item.b64_json === 'string' && item.b64_json.trim()) {
return { kind: 'base64', value: item.b64_json.trim(), revisedPrompt: item.revised_prompt ?? null };
}
if (typeof item.url === 'string' && item.url.trim()) {
return { kind: 'url', value: item.url.trim(), revisedPrompt: item.revised_prompt ?? null };
}
return null;
}
async function loadProviderCredentials(pool, encryptionKey) {
const [rows] = await pool.query(
`SELECT api_url, api_key_ciphertext, api_key_iv, api_key_tag, relay_provider
FROM h5_llm_provider_keys
WHERE is_selected = 1 AND status = 'active'
LIMIT 1`,
);
const row = rows[0];
if (!row) {
throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'image_gen_not_configured' });
}
let apiKey;
try {
apiKey = decryptSecret(
{
ciphertext: row.api_key_ciphertext,
iv: row.api_key_iv,
tag: row.api_key_tag,
},
resolveEncryptionKey(encryptionKey),
);
} catch {
throw Object.assign(new Error('LLM 密钥解密失败,请重新配置'), { code: 'image_gen_not_configured' });
}
return {
apiUrl: normalizeApiUrl(row.api_url),
apiKey,
relayProvider: row.relay_provider ?? null,
};
}
async function resolveProvider(pool, encryptionKey) {
const config = loadImageGenConfig();
if (config.apiUrl && config.apiKey) {
return {
url: resolveImagesGenerationsUrl(config.apiUrl),
apiKey: config.apiKey,
model: config.model,
relayProvider: null,
};
}
const provider = await loadProviderCredentials(pool, encryptionKey);
const chatUrl = resolveChatCompletionsUrl(provider.apiUrl);
return {
url: resolveImagesGenerationsUrl(chatUrl || provider.apiUrl),
apiKey: provider.apiKey,
model: config.model,
relayProvider: provider.relayProvider,
};
}
async function downloadImageBuffer(payload) {
if (payload.kind === 'base64') {
return Buffer.from(payload.value, 'base64');
}
const response = await undiciFetch(payload.value, {
dispatcher: payload.value.startsWith('https://') ? insecureDispatcher : undefined,
});
if (!response.ok) {
throw Object.assign(new Error(`下载生成图片失败 (${response.status})`), { code: 'image_gen_failed' });
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
function mimeTypeForPath(relativePath) {
switch (path.extname(relativePath).toLowerCase()) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.webp':
return 'image/webp';
default:
return 'image/png';
}
}
export function buildImageGenerateCurlExample({ h5ApiBase, sessionId = '<当前会话ID>' } = {}) {
const base = String(h5ApiBase ?? 'http://127.0.0.1:8081').replace(/\/$/, '');
return [
'```bash',
`curl -sS -X POST '${base}/api/agent/generate_image' \\`,
" -H 'Content-Type: application/json' \\",
` -d '{"session_id":"${sessionId}","prompt":"A premium travel poster for Malaysia, cinematic sunset, editorial photography","output_path":"assets/malaysia-hero.png","style_notes":"modern editorial, warm palette","aspect_ratio":"3:4 portrait"}'`,
'```',
].join('\n');
}
export function createImageGenerationService({
pool,
h5Root,
encryptionKey,
resolveUserIdForAgentSession,
publicBaseUrl,
}) {
async function generateForAgentSession(input = {}) {
const config = loadImageGenConfig();
if (!config.enabled) {
throw Object.assign(new Error('图片生成未启用'), { code: 'image_gen_unavailable' });
}
const sessionId = String(input.sessionId ?? input.session_id ?? '').trim();
const prompt = String(input.prompt ?? '').trim();
if (!sessionId) {
throw Object.assign(new Error('缺少 session_id'), { code: 'invalid_request' });
}
if (!prompt) {
throw Object.assign(new Error('缺少 prompt'), { code: 'invalid_request' });
}
if (prompt.length > 4000) {
throw Object.assign(new Error('prompt 过长'), { code: 'invalid_request' });
}
const userId = await resolveUserIdForAgentSession(sessionId);
if (!userId) {
throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' });
}
const user = { id: userId };
const relativePath = normalizeImageOutputPath(input.outputPath ?? input.output_path, prompt);
const workspaceRoot = resolveUserWorkspaceRoot(h5Root, user);
const absolutePath = resolveWorkspaceOutputPath(workspaceRoot, relativePath);
const size = ALLOWED_SIZES.has(String(input.size ?? '').trim())
? String(input.size).trim()
: config.defaultSize;
const style = ALLOWED_STYLES.has(String(input.style ?? '').trim())
? String(input.style).trim()
: config.defaultStyle;
const quality = ALLOWED_QUALITIES.has(String(input.quality ?? '').trim())
? String(input.quality).trim()
: config.defaultQuality;
const designerPrompt = composeDesignerPrompt({
prompt,
styleNotes: input.styleNotes ?? input.style_notes,
aspectRatio: input.aspectRatio ?? input.aspect_ratio,
brandColors: input.brandColors ?? input.brand_colors,
subject: input.subject,
});
const provider = await resolveProvider(pool, encryptionKey);
if (!provider.url) {
throw Object.assign(new Error('图片生成 API 地址无效'), { code: 'image_gen_not_configured' });
}
let upstream;
try {
upstream = await undiciFetch(provider.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${provider.apiKey}`,
},
body: JSON.stringify({
model: provider.model,
prompt: designerPrompt,
n: 1,
size,
style,
quality,
response_format: 'b64_json',
...(provider.relayProvider ? { provider: provider.relayProvider } : {}),
}),
dispatcher: provider.url.startsWith('https://') ? insecureDispatcher : undefined,
});
} catch (error) {
throw Object.assign(
new Error(`图片生成失败:${error instanceof Error ? error.message : '网络错误'}`),
{ code: 'image_gen_failed' },
);
}
const text = await upstream.text().catch(() => '');
if (!upstream.ok) {
throw Object.assign(new Error(`图片生成失败 (${upstream.status})`), {
code: 'image_gen_failed',
details: text.slice(0, 500),
});
}
let data;
try {
data = JSON.parse(text);
} catch {
throw Object.assign(new Error('图片生成响应不是 JSON'), { code: 'image_gen_failed' });
}
const payload = parseImagePayload(data);
if (!payload) {
throw Object.assign(new Error('图片生成 API 未返回图片'), { code: 'image_gen_failed' });
}
const buffer = await downloadImageBuffer(payload);
if (buffer.length === 0) {
throw Object.assign(new Error('图片内容为空'), { code: 'image_gen_failed' });
}
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, buffer);
const publishKey = resolvePublishKey(user);
const publicUrl = buildPublicUrl(publicBaseUrl, publishKey, relativePath);
return {
relativePath,
absolutePath,
publicUrl,
mimeType: mimeTypeForPath(relativePath),
size,
style,
quality,
revisedPrompt: payload.revisedPrompt,
prompt,
designerPrompt,
};
}
return { generateForAgentSession };
}