/** * 镭射五图生成 composable * 兼容两种后端提供方: * - Dify (LASER_GEN_PROVIDER=dify): 单次 blocking POST, 直接返回 variants * - MiniMax (LASER_GEN_PROVIDER=minimax): 异步, POST 后轮询 * * 选择方式: 根据 POST 响应结构自动判断 * - 含 job_id → 走轮询 (MiniMax 路径) * - 含 variants → 走阻塞等待 (Dify 路径) */ import { ref } from 'vue' import { getLaserApiBaseUrl } from '@/utils/api.js' const DEFAULT_TIMEOUT_MS = 60000 const DIFY_BLOCKING_TIMEOUT_MS = 600000 // 中转站生图最慢可达 5min+, 客户端 600s 留余量 /** * 镭射专用请求(直接请求本地 Gateway,不走远程 DEV_BASE) * 返回格式与 api.js 的 request() 一致:{ code, message, data } * * 注意:uni-app 移动端没有 fetch 全局,必须用 uni.request 封装 Promise */ async function laserRequest(opts) { const base = getLaserApiBaseUrl() const url = `${base}${opts.url}` const headers = { 'Content-Type': 'application/json' } const token = uni.getStorageSync('access_token') if (token) { headers['Authorization'] = `Bearer ${token}` } return new Promise((resolve, reject) => { uni.request({ url, method: opts.method || 'GET', data: opts.data || {}, header: headers, timeout: opts.timeout || DEFAULT_TIMEOUT_MS, success: (res) => { if (res.statusCode < 200 || res.statusCode >= 300) { reject(new Error(res.data?.message || `HTTP ${res.statusCode}`)) return } // 兼容后端返回的两种结构:{code, message, data} 或 裸 data resolve(res.data) }, fail: (err) => { reject(new Error(err.errMsg || '网络请求失败')) }, }) }) } export function useLaserDifyGenerate() { const jobId = ref('') const status = ref('idle') // idle | submitting | processing | done | error | timeout const progress = ref(0) const variants = ref([]) // [{ preset_id, signed_url, oss_key }] const instanceNo = ref('') const cutoutUrl = ref('') const error = ref(null) let pollTimer = null let pollingSince = 0 let _resolveSubmit = null let _rejectSubmit = null /** * 检测字符串是否包含中文 */ function containsChinese(text) { return /[一-鿿㐀-䶿]/.test(text) } /** * 翻译中文到英文(使用 Google 免费翻译 API) */ async function translateToEnglish(text) { try { const res = await fetch( 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=zh-CN&tl=en&dt=t&q=' + encodeURIComponent(text) ) const data = await res.json() if (data && data[0]) { return data[0].map(function(seg) { return seg[0] }).join(' ').trim() } } catch (e) { console.warn('[translate] failed, use original:', e) } return text } /** * 生成 render_configs:完全由用户输入决定 prompt * - 无抽卡、无风格池、无 grating_config * - 用户输入中文自动翻译成英文 * - 每次返回 5 个 variant(同一 prompt,不同 preset_id) * * @param {string[]|null} _presetIds 忽略,保留参数兼容 * @param {string} userPrompt * @returns {Promise>} */ async function resolveRenderConfigs(_presetIds, userPrompt) { let prompt = (userPrompt || '').trim().slice(0, 1000) if (prompt && containsChinese(prompt)) { console.log('[translate] Chinese detected, translating...') prompt = await translateToEnglish(prompt) console.log('[translate] result:', prompt) } // 4 路并发 AI 生图 + 1 张原图 = 总共 5 张 return [1, 2, 3, 4].map(function(i) { return { preset_id: 'v' + i, bg_prompt: prompt || 'Transform this into a premium holographic artwork', } }) } /** * 提交生成任务到 Gateway * 自动适配 Dify 阻塞 / MiniMax 异步两种模式 * * @param {string} cutoutUrl 抠图后的 OSS URL(空串表示不叠加人像) * @param {string[]} [presetIds] 需要生成的 preset ID 列表 * @param {string} [userPrompt] 用户自定义 prompt,注入到 AI 生图 */ async function submit(cutoutUrl, presetIds = null, userPrompt = '') { status.value = 'submitting' error.value = null progress.value = 0 const renderConfigs = await resolveRenderConfigs(presetIds, userPrompt) const presetCodes = renderConfigs.map((rc) => rc.preset_id) try { // Dify 阻塞模式单次请求可能耗时 30-120s, 先用较长 timeout const res = await laserRequest({ url: '/api/v1/laser/generate', method: 'POST', timeout: DIFY_BLOCKING_TIMEOUT_MS, data: { cutout_url: cutoutUrl, preset_codes: presetCodes, render_configs: renderConfigs, // userPrompt 已经包装进 render_configs.bg_prompt,这里传空避免后端二次拼接 user_prompt: '', }, }) if (!res || !res.data) { throw new Error('生成任务失败: 响应为空') } const payload = res.data // 模式 1: Dify 阻塞 — 响应直接含 variants if (payload.status === 'succeeded' && Array.isArray(payload.variants)) { applySucceeded(payload, cutoutUrl) return } // 模式 1.5: Dify 失败 if (payload.status === 'failed') { throw new Error(payload.error || 'Dify 工作流执行失败') } // 模式 2: MiniMax 异步 — 响应含 job_id, 进入轮询 if (payload.job_id) { jobId.value = payload.job_id status.value = 'processing' progress.value = 0.05 pollingSince = Date.now() await new Promise((resolve, reject) => { _resolveSubmit = resolve _rejectSubmit = reject startPolling() }) return } throw new Error('生成任务失败: 响应格式无法识别 (既无 job_id 也无 variants)') } catch (e) { status.value = 'error' error.value = e throw e } } /** * 应用 Dify 阻塞模式的成功响应 */ function applySucceeded(payload, fallbackCutoutUrl) { var aiVariants = Array.isArray(payload.variants) ? payload.variants : [] cutoutUrl.value = payload.cutout_url || fallbackCutoutUrl || '' // 把原图也作为一个 variant 展示(第 5 张) if (cutoutUrl.value) { aiVariants.push({ preset_id: 'original', signed_url: cutoutUrl.value, oss_key: '', }) } variants.value = aiVariants instanceNo.value = payload.instance_no || '' progress.value = 1.0 status.value = 'done' } function startPolling() { const pollInterval = 2000 // 每 2 秒轮询 const maxDuration = 120000 // 最长等待 120 秒 const poll = () => { if (status.value !== 'processing') return // 超时降级 if (Date.now() - pollingSince > maxDuration) { const err = new Error('生成超时, 已降级到本地合成') status.value = 'timeout' error.value = err stopPolling() if (_rejectSubmit) { _rejectSubmit(err); _rejectSubmit = null } return } laserRequest({ url: `/api/v1/laser/generate/${jobId.value}`, method: 'GET', }) .then((res) => { if (!res || !res.data) return // 更新进度 if (res.data.progress != null) { progress.value = res.data.progress } if (res.data.status === 'succeeded') { applySucceeded(res.data, '') stopPolling() if (_resolveSubmit) { _resolveSubmit(); _resolveSubmit = null } } else if (res.data.status === 'failed') { const err = new Error(res.data.error || '生成任务失败') status.value = 'error' error.value = err stopPolling() if (_rejectSubmit) { _rejectSubmit(err); _rejectSubmit = null } } else { // processing — 继续轮询 pollTimer = setTimeout(poll, pollInterval) } }) .catch((err) => { console.warn('[useLaserDifyGenerate] poll error:', err) pollTimer = setTimeout(poll, pollInterval) }) } poll() } function stopPolling() { if (pollTimer) { clearTimeout(pollTimer) pollTimer = null } } function reset() { stopPolling() jobId.value = '' status.value = 'idle' progress.value = 0 variants.value = [] cutoutUrl.value = '' error.value = null pollingSince = 0 } /** * 获取 variant 图片 URL 列表(与 client 模式兼容的格式) */ function getVariantUrls() { return variants.value.map((v) => v.signed_url || v.url || '') } /** * 获取 variant 完整信息(含 oss_key),铸造时直接用 oss_key 避免 HEAD 请求 * @returns {Array<{ url: string, oss_key: string }>} */ function getVariantInfos() { return variants.value.map((v) => ({ url: v.signed_url || v.url || '', oss_key: v.oss_key || '', })) } function getInstanceNo() { return instanceNo.value } return { jobId, status, progress, variants, cutoutUrl, error, submit, reset, getVariantUrls, getVariantInfos, getInstanceNo, stopPolling, } }