- 替换中转站从 xbcl.link 到 weda.cc - prompt 模板改为镭射卡全图生成(去掉 6 层合成/抠图依赖) - 4 路并发调用 + 原图展示 = 5 张 variant - 前端提示词中译英支持 - 全局 Vue errorHandler - WebSocket 鉴权失败跳登录 - 删除已弃用的 laserCompositor 微服务 Co-Authored-By: Claude <noreply@anthropic.com>
317 lines
8.6 KiB
JavaScript
317 lines
8.6 KiB
JavaScript
/**
|
||
* 镭射五图生成 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,
|
||
}
|
||
}
|