topfans/frontend/composables/useLaserDifyGenerate.js
Lenticular Studio Agent af7908e72e feat: 接入微达API中转站,重构镭射卡生图流程
- 替换中转站从 xbcl.link 到 weda.cc
- prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖)
- 4 路并发调用 + 原图展示 = 5 张 variant
- 前端提示词中译英支持
- 全局 Vue errorHandler
- WebSocket 鉴权失败跳登录
- 删除已弃用的 laserCompositor 微服务

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 22:43:49 +08:00

317 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 镭射五图生成 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<Array<{preset_id: string, bg_prompt: string}>>}
*/
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,
}
}