Files
20260512-skg-tk/web/canvas-app/src/hooks/useApi.js
2026-05-26 10:08:22 +08:00

305 lines
10 KiB
JavaScript

import { ref, reactive, onUnmounted } from 'vue'
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const toAssetUrl = (path) => {
if (!path) return ''
if (/^(https?:|blob:|data:)/i.test(path)) return path
return apiUrl(path)
}
const parseApiError = async (response, fallback) => {
const text = await response.text().catch(() => '')
try {
const parsed = JSON.parse(text)
return parsed?.detail || parsed?.error || fallback
} catch {
return text || fallback
}
}
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
headers: {
...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(init.headers || {})
}
})
if (!response.ok) {
throw new Error(await parseApiError(response, `${path} ${response.status}`))
}
return response.json()
}
const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => {
const [meta, payload] = dataUrl.split(',')
const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg'
const binary = atob(payload || '')
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return new File([bytes], filename, { type: mime })
}
const imageSourceToFile = async (source, filename = 'reference.jpg') => {
if (!source) return null
if (source instanceof File) return source
if (typeof source !== 'string') return null
if (source.startsWith('data:')) return dataUrlToFile(source, filename)
const url = source.startsWith('/jobs/') ? apiUrl(source) : source
const response = await fetch(url)
if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`)
const blob = await response.blob()
return new File([blob], filename, { type: blob.type || 'image/jpeg' })
}
const createCreativeImageJob = async (file = null) => {
if (file) {
const form = new FormData()
form.append('file', file)
return requestJson('/creative/jobs/image', { method: 'POST', body: form })
}
return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) })
}
const uploadReferenceFrame = async (jobId, file) => {
const form = new FormData()
form.append('file', file)
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
}
const newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
}
const newestGeneratedVideo = (job) => (
[...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
)
const normalizeVideoSize = (value) => {
const raw = String(value || '').trim().toLowerCase()
const map = {
'9:16': '720x1280',
'9x16': '720x1280',
'vertical': '720x1280',
'portrait': '720x1280',
'16:9': '1280x720',
'16x9': '1280x720',
'horizontal': '1280x720',
'landscape': '1280x720',
'1:1': '1024x1024',
'1x1': '1024x1024',
'3:4': '960x1280',
'3x4': '960x1280'
}
if (/^\d+x\d+$/.test(raw)) return raw
return map[raw] || '720x1280'
}
export const useApiState = () => {
const loading = ref(false)
const error = ref(null)
const status = ref('idle')
const reset = () => {
loading.value = false
error.value = null
status.value = 'idle'
}
const setLoading = (isLoading) => {
loading.value = isLoading
status.value = isLoading ? 'running' : status.value
}
const setError = (err) => {
error.value = err
status.value = 'error'
loading.value = false
}
const setSuccess = () => {
status.value = 'success'
loading.value = false
error.value = null
}
return { loading, error, status, reset, setLoading, setError, setSuccess }
}
export const useChat = (options = {}) => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const messages = ref([])
const currentResponse = ref('')
let stopped = false
const send = async (content) => {
setLoading(true)
stopped = false
try {
const mode = options.mode || 'chat'
const response = await requestJson('/prompt/polish', {
method: 'POST',
body: JSON.stringify({
text: content,
system_prompt: options.systemPrompt || '',
mode,
target_language: options.targetLanguage || (mode === 'chat' ? 'keep' : 'en')
})
})
const result = response.text || content
if (!stopped) {
currentResponse.value = result
messages.value.push({ role: 'user', content })
messages.value.push({ role: 'assistant', content: result })
}
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
const stop = () => {
stopped = true
}
const clear = () => {
messages.value = []
currentResponse.value = ''
reset()
}
onUnmounted(() => stop())
return { loading, error, status, messages, currentResponse, send, stop, clear, reset }
}
export const useImageGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const images = ref([])
const currentImage = ref(null)
const generate = async (params) => {
setLoading(true)
images.value = []
currentImage.value = null
try {
const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : [])
const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null
const job = await createCreativeImageJob(firstRef)
const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
model: params.model || 'auto',
size: params.size || '1024x1536',
mode: firstRef ? 'edit' : 'text'
})
})
const generated = newestGeneratedImage(updated, 0)
if (!generated?.url) throw new Error('图片生成完成但未返回地址')
const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }]
images.value = result
currentImage.value = result[0]
setSuccess()
return result
} catch (err) {
setError(err)
throw err
}
}
return { loading, error, status, images, currentImage, generate, reset }
}
export const useVideoGeneration = () => {
const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState()
const video = ref(null)
const taskId = ref(null)
const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 })
const createVideoTaskOnly = async (params) => {
setLoading(true)
try {
const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null
let job = await createCreativeImageJob(firstFile)
let lastFrameIdx = null
if (params.last_frame_image) {
const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg')
if (lastFile) {
job = await uploadReferenceFrame(job.id, lastFile)
lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index))
}
}
const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, {
method: 'POST',
body: JSON.stringify({
prompt: params.prompt || '',
duration: Number(params.dur || params.duration || params.seconds || 10),
count: 1,
first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null,
last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null,
model: params.model || 'seedance',
size: normalizeVideoSize(params.ratio || params.size)
})
})
const created = newestGeneratedVideo(updated)
if (!created?.id) throw new Error('视频任务已提交但未返回任务编号')
const id = `skg:${updated.id}:${created.id}`
taskId.value = id
status.value = 'polling'
setSuccess()
return { taskId: id }
} catch (err) {
setError(err)
throw err
}
}
const pollVideoTask = async (pollTaskId, onProgress = () => {}) => {
const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || ''))
if (!match) throw new Error('未知视频任务类型')
const [, jobId, videoId] = match
const maxAttempts = 180
const interval = 5000
for (let i = 0; i < maxAttempts; i += 1) {
const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' })
const item = (job.generated_videos || []).find(v => v.id === videoId)
if (!item) throw new Error('视频任务不存在')
const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98)
onProgress(i + 1, percentage)
progress.attempt = i + 1
progress.percentage = percentage
if (item.status === 'completed') {
const result = { ...item, url: toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) }
video.value = result
setSuccess()
return result
}
if (item.status === 'failed') {
throw new Error(item.error || '视频生成失败')
}
await new Promise(resolve => setTimeout(resolve, interval))
}
throw new Error('视频生成超时')
}
const generate = async (params) => {
const { taskId: newTaskId, url } = await createVideoTaskOnly(params)
if (url) {
video.value = { url }
return video.value
}
return pollVideoTask(newTaskId)
}
return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask }
}
export const useApi = () => {
const chat = useChat()
const image = useImageGeneration()
const videoGen = useVideoGeneration()
return { config: {}, chat, image, video: videoGen }
}