| 1 | /** |
| 2 | * 工作流 API 客户端 |
| 3 | */ |
| 4 | |
| 5 | /** |
| 6 | * Streaming endpoints must bypass the Next.js rewrite proxy because it buffers |
| 7 | * the entire upstream response before forwarding, which breaks SSE real-time delivery. |
| 8 | * Non-streaming endpoints can still go through the proxy (relative URL). |
| 9 | */ |
| 10 | export const DIRECT_API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000'; |
| 11 | const STREAM_API_BASE = DIRECT_API_BASE; |
| 12 | |
| 13 | export interface StageInfo { |
| 14 | id: string; |
| 15 | name: string; |
| 16 | order: number; |
| 17 | description: string; |
| 18 | } |
| 19 | |
| 20 | export interface ProjectStatus { |
| 21 | session_id: string; |
| 22 | current_stage: string; |
| 23 | status: Record<string, string>; |
| 24 | error: string | null; |
| 25 | } |
| 26 | |
| 27 | export interface StreamEvent { |
| 28 | type: 'progress' | 'heartbeat' | 'stage_complete' | 'error' | 'content'; |
| 29 | message?: string; |
| 30 | phase?: string; |
| 31 | step_desc?: string; |
| 32 | percent?: number; |
| 33 | stage?: string; |
| 34 | status?: string; |
| 35 | requires_intervention?: boolean; |
| 36 | payload_summary?: any; |
| 37 | content?: string; |
| 38 | time?: number; |
| 39 | data?: any; |
| 40 | } |
| 41 | |
| 42 | export interface PipelineTask { |
| 43 | task_id: string; |
| 44 | pipeline: string; |
| 45 | status: 'pending' | 'running' | 'completed' | 'failed' | string; |
| 46 | progress?: number; |
| 47 | message?: string; |
| 48 | input?: Record<string, any>; |
| 49 | output?: Record<string, any>; |
| 50 | artifacts?: Array<{ kind: string; name?: string; path: string; exists?: boolean; created_at?: string }>; |
| 51 | error?: string | null; |
| 52 | created_at?: string; |
| 53 | updated_at?: string; |
| 54 | output_dir?: string; |
| 55 | } |
| 56 | |
| 57 | export interface SandboxTask { |
| 58 | id: string; |
| 59 | tool: string; |
| 60 | model: string; |
| 61 | input?: Record<string, any>; |
| 62 | status: string; |
| 63 | progress?: number; |
| 64 | created_at?: string; |
| 65 | } |
| 66 | |
| 67 | export interface PipelineStartResponse { |
| 68 | task_id: string; |
| 69 | pipeline: string; |
| 70 | status: string; |
| 71 | metadata_url: string; |
| 72 | output_dir: string; |
| 73 | } |
| 74 | |
| 75 | export interface PipelineTaskEvent { |
| 76 | type: 'snapshot' | 'progress' | 'artifact' | 'completed' | 'failed'; |
| 77 | task_id: string; |
| 78 | status?: string; |
| 79 | progress?: number; |
| 80 | artifact?: { kind: string; name?: string; path: string; exists?: boolean; created_at?: string }; |
| 81 | } |
| 82 | |
| 83 | export interface ApiModelOption { |
| 84 | id: string; |
| 85 | label: string; |
| 86 | provider: string; |
| 87 | family?: string; |
| 88 | media_type?: 'image' | 'video'; |
| 89 | model_type?: 'llm' | 'vlm' | 't2i' | 'i2i' | 'video'; |
| 90 | type?: string[]; |
| 91 | ability_type?: string; |
| 92 | ability_types?: string[]; |
| 93 | adapter_ability_types?: string[]; |
| 94 | input_modalities?: string[]; |
| 95 | adapter_input_modalities?: string[]; |
| 96 | api_contract_verified?: boolean; |
| 97 | capabilities?: Record<string, any>; |
| 98 | } |
| 99 | |
| 100 | export interface StandardTemplateOption { |
| 101 | id: string; |
| 102 | name: string; |
| 103 | label: string; |
| 104 | size: string; |
| 105 | ratio: '9:16' | '1:1' | '16:9' | string; |
| 106 | width: number; |
| 107 | height: number; |
| 108 | media_width: number; |
| 109 | media_height: number; |
| 110 | media_ratio: string; |
| 111 | media_resolution: string; |
| 112 | supports_video?: boolean; |
| 113 | fields: Array<{ key: string; type: string; default: string }>; |
| 114 | preview_url: string; |
| 115 | } |
| 116 | |
| 117 | export async function fetchStages(): Promise<StageInfo[]> { |
| 118 | const resp = await fetch('/api/stages'); |
| 119 | const data = await resp.json(); |
| 120 | return data.stages; |
| 121 | } |
| 122 | |
| 123 | export async function fetchSessions(): Promise<any[]> { |
| 124 | const resp = await fetch('/api/sessions'); |
| 125 | const data = await resp.json(); |
| 126 | return data.sessions || []; |
| 127 | } |
| 128 | |
| 129 | async function postPipelineTask(path: string, params: Record<string, any>): Promise<PipelineStartResponse> { |
| 130 | const resp = await fetch(path, { |
| 131 | method: 'POST', |
| 132 | headers: { 'Content-Type': 'application/json' }, |
| 133 | body: JSON.stringify(params), |
| 134 | }); |
| 135 | if (!resp.ok) { |
| 136 | const err = await resp.json().catch(() => ({ detail: '启动任务失败' })); |
| 137 | throw new Error(err.detail || '启动任务失败'); |
| 138 | } |
| 139 | return resp.json(); |
| 140 | } |
| 141 | |
| 142 | export async function startStandardPipeline(params: Record<string, any>): Promise<PipelineStartResponse> { |
| 143 | return postPipelineTask('/api/pipelines/standard/tasks', params); |
| 144 | } |
| 145 | |
| 146 | export async function startActionTransferPipeline(params: Record<string, any>): Promise<PipelineStartResponse> { |
| 147 | return postPipelineTask('/api/pipelines/action_transfer/tasks', params); |
| 148 | } |
| 149 | |
| 150 | export async function startDigitalHumanPipeline(params: Record<string, any>): Promise<PipelineStartResponse> { |
| 151 | return postPipelineTask('/api/pipelines/digital_human/tasks', params); |
| 152 | } |
| 153 | |
| 154 | export async function fetchPipelineTasks(limit = 100): Promise<PipelineTask[]> { |
| 155 | const resp = await fetch(`/api/tasks?limit=${limit}`); |
| 156 | if (!resp.ok) throw new Error('获取任务历史失败'); |
| 157 | const data = await resp.json(); |
| 158 | return data.tasks || []; |
| 159 | } |
| 160 | |
| 161 | export async function fetchPipelineTask(taskId: string): Promise<PipelineTask> { |
| 162 | const resp = await fetch(`/api/tasks/${taskId}`); |
| 163 | if (!resp.ok) throw new Error('获取任务状态失败'); |
| 164 | return resp.json(); |
| 165 | } |
| 166 | |
| 167 | export async function fetchSandboxTasks(): Promise<SandboxTask[]> { |
| 168 | const resp = await fetch('/api/sandbox/tasks'); |
| 169 | if (!resp.ok) return []; |
| 170 | const data = await resp.json(); |
| 171 | return data.tasks || []; |
| 172 | } |
| 173 | |
| 174 | export async function clearTempCache(): Promise<{ status: string; deleted: number; freed_bytes?: number; freed_mb?: number; errors?: Array<{ path: string; error: string }> }> { |
| 175 | const resp = await fetch('/api/cache/temp', { method: 'DELETE' }); |
| 176 | if (!resp.ok) { |
| 177 | const err = await resp.json().catch(() => ({ detail: '清空缓存失败' })); |
| 178 | throw new Error(err.detail || '清空缓存失败'); |
| 179 | } |
| 180 | return resp.json(); |
| 181 | } |
| 182 | |
| 183 | export async function deletePipelineTask(taskId: string): Promise<void> { |
| 184 | const resp = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' }); |
| 185 | if (!resp.ok) throw new Error('删除任务失败'); |
| 186 | } |
| 187 | |
| 188 | export async function fetchApiModels(params: { |
| 189 | mediaType?: 'image' | 'video'; |
| 190 | modelType?: 'llm' | 'vlm' | 't2i' | 'i2i' | 'video'; |
| 191 | ability?: string; |
| 192 | verifiedOnly?: boolean; |
| 193 | } = {}): Promise<ApiModelOption[]> { |
| 194 | const search = new URLSearchParams(); |
| 195 | if (params.mediaType) search.set('media_type', params.mediaType); |
| 196 | if (params.modelType) search.set('model_type', params.modelType); |
| 197 | if (params.ability) search.set('ability', params.ability); |
| 198 | if (params.verifiedOnly) search.set('verified_only', 'true'); |
| 199 | const resp = await fetch(`/api/models${search.toString() ? `?${search.toString()}` : ''}`); |
| 200 | if (!resp.ok) throw new Error('获取模型列表失败'); |
| 201 | const data = await resp.json(); |
| 202 | return data.models || []; |
| 203 | } |
| 204 | |
| 205 | export async function fetchStandardTemplates(): Promise<StandardTemplateOption[]> { |
| 206 | const resp = await fetch('/api/pipelines/standard/templates'); |
| 207 | if (!resp.ok) throw new Error('获取模版列表失败'); |
| 208 | const data = await resp.json(); |
| 209 | return data.templates || []; |
| 210 | } |
| 211 | |
| 212 | export async function uploadMedia(file: File): Promise<{ filename: string; file_path: string }> { |
| 213 | const formData = new FormData(); |
| 214 | formData.append('file', file); |
| 215 | const resp = await fetch('/api/upload_media', { |
| 216 | method: 'POST', |
| 217 | body: formData, |
| 218 | }); |
| 219 | if (!resp.ok) { |
| 220 | const err = await resp.json().catch(() => ({ detail: '上传失败' })); |
| 221 | throw new Error(err.detail || '上传失败'); |
| 222 | } |
| 223 | return resp.json(); |
| 224 | } |
| 225 | |
| 226 | export async function uploadArtifactImage( |
| 227 | sessionId: string, |
| 228 | stage: string, |
| 229 | itemType: string, |
| 230 | itemId: string, |
| 231 | file: File, |
| 232 | ): Promise<{ status: string; path: string; artifact: any; status_map: Record<string, string> }> { |
| 233 | const formData = new FormData(); |
| 234 | formData.append('item_type', itemType); |
| 235 | formData.append('item_id', itemId); |
| 236 | formData.append('file', file); |
| 237 | const resp = await fetch(`/api/project/${sessionId}/artifact/${stage}/upload_image`, { |
| 238 | method: 'POST', |
| 239 | body: formData, |
| 240 | }); |
| 241 | if (!resp.ok) { |
| 242 | const err = await resp.json().catch(() => ({ detail: '上传图片失败' })); |
| 243 | throw new Error(err.detail || '上传图片失败'); |
| 244 | } |
| 245 | return resp.json(); |
| 246 | } |
| 247 | |
| 248 | export function subscribePipelineTask( |
| 249 | taskId: string, |
| 250 | onEvent: (event: PipelineTaskEvent) => void, |
| 251 | onError?: () => void, |
| 252 | ): () => void { |
| 253 | const source = new EventSource(`${STREAM_API_BASE}/api/tasks/${taskId}/events`); |
| 254 | source.onmessage = event => { |
| 255 | try { |
| 256 | onEvent(JSON.parse(event.data)); |
| 257 | } catch { |
| 258 | // Ignore malformed stream events. |
| 259 | } |
| 260 | }; |
| 261 | source.onerror = () => { |
| 262 | onError?.(); |
| 263 | source.close(); |
| 264 | }; |
| 265 | return () => source.close(); |
| 266 | } |
| 267 | |
| 268 | export async function startProject(params: { |
| 269 | idea: string; |
| 270 | file_path?: string; |
| 271 | style?: string; |
| 272 | video_ratio?: string; |
| 273 | video_resolution?: string; |
| 274 | llm_model?: string; |
| 275 | vlm_model?: string; |
| 276 | image_t2i_model?: string; |
| 277 | image_it2i_model?: string; |
| 278 | video_model?: string; |
| 279 | video_first_frame_model?: string; |
| 280 | video_start_end_model?: string; |
| 281 | video_reference_model?: string; |
| 282 | video_generation_mode?: string; |
| 283 | scene_number?: number; |
| 284 | enable_concurrency?: boolean; |
| 285 | web_search?: boolean; |
| 286 | expand_idea?: boolean; |
| 287 | episodes?: number; |
| 288 | }): Promise<{ session_id: string; status: string; params: any }> { |
| 289 | const resp = await fetch('/api/project/start', { |
| 290 | method: 'POST', |
| 291 | headers: { 'Content-Type': 'application/json' }, |
| 292 | body: JSON.stringify(params), |
| 293 | }); |
| 294 | if (!resp.ok) { |
| 295 | const err = await resp.json().catch(() => ({ detail: '项目创建失败' })); |
| 296 | throw new Error(err.detail || '项目创建失败'); |
| 297 | } |
| 298 | return resp.json(); |
| 299 | } |
| 300 | |
| 301 | export async function getProjectStatus(sessionId: string): Promise<ProjectStatus> { |
| 302 | const resp = await fetch(`/api/project/${sessionId}/status`); |
| 303 | if (!resp.ok) throw new Error('Failed to get project status'); |
| 304 | return resp.json(); |
| 305 | } |
| 306 | |
| 307 | // 兼容旧路由名;后端实际会通过统一的 workflow state 入口返回状态。 |
| 308 | export async function getProjectStatusFromDisk(sessionId: string): Promise<any> { |
| 309 | const resp = await fetch(`/api/project/${sessionId}/status/from_disk`); |
| 310 | if (!resp.ok) throw new Error('Failed to get project status snapshot'); |
| 311 | return resp.json(); |
| 312 | } |
| 313 | |
| 314 | export async function getArtifact(sessionId: string, stage: string): Promise<any> { |
| 315 | const resp = await fetch(`/api/project/${sessionId}/artifact/${stage}`); |
| 316 | if (!resp.ok) throw new Error(`Artifact for stage '${stage}' not found`); |
| 317 | return resp.json(); |
| 318 | } |
| 319 | |
| 320 | export async function checkSceneAssets(sessionId: string, sceneNumber: number): Promise<{ |
| 321 | scene_number: number; |
| 322 | reference_images: number; |
| 323 | videos: number; |
| 324 | shot_count: number; |
| 325 | }> { |
| 326 | const resp = await fetch(`/api/project/${sessionId}/scene/${sceneNumber}/assets`); |
| 327 | if (!resp.ok) return { scene_number: sceneNumber, reference_images: 0, videos: 0, shot_count: 0 }; |
| 328 | return resp.json(); |
| 329 | } |
| 330 | |
| 331 | export async function executeStage( |
| 332 | sessionId: string, |
| 333 | stage: string, |
| 334 | inputData: Record<string, any> = {}, |
| 335 | signal?: AbortSignal, |
| 336 | ): Promise<Response> { |
| 337 | return fetch(`${STREAM_API_BASE}/api/project/${sessionId}/execute/${stage}`, { |
| 338 | method: 'POST', |
| 339 | headers: { 'Content-Type': 'application/json' }, |
| 340 | body: JSON.stringify(inputData), |
| 341 | signal, |
| 342 | }); |
| 343 | } |
| 344 | |
| 345 | export async function intervene( |
| 346 | sessionId: string, |
| 347 | stage: string, |
| 348 | modifications: Record<string, any>, |
| 349 | ): Promise<Response> { |
| 350 | // Use STREAM_API_BASE to bypass Next.js proxy (SSE endpoint) |
| 351 | return fetch(`${STREAM_API_BASE}/api/project/${sessionId}/intervene`, { |
| 352 | method: 'POST', |
| 353 | headers: { 'Content-Type': 'application/json' }, |
| 354 | body: JSON.stringify({ stage, modifications }), |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | export async function stopProject(sessionId: string): Promise<{ status: string }> { |
| 359 | const resp = await fetch(`/api/project/${sessionId}/stop`, { |
| 360 | method: 'POST', |
| 361 | }); |
| 362 | return resp.json(); |
| 363 | } |
| 364 | |
| 365 | export async function updateModels( |
| 366 | sessionId: string, |
| 367 | models: Partial<Record<string, string | boolean>>, |
| 368 | ): Promise<{ status: string }> { |
| 369 | const resp = await fetch(`/api/project/${sessionId}/models`, { |
| 370 | method: 'PATCH', |
| 371 | headers: { 'Content-Type': 'application/json' }, |
| 372 | body: JSON.stringify(models), |
| 373 | }); |
| 374 | return resp.json(); |
| 375 | } |
| 376 | |
| 377 | export async function deleteSession( |
| 378 | sessionId: string, |
| 379 | ): Promise<{ status: string }> { |
| 380 | const resp = await fetch(`/api/sessions/${sessionId}`, { |
| 381 | method: 'DELETE', |
| 382 | headers: { 'Content-Type': 'application/json' }, |
| 383 | }); |
| 384 | if (!resp.ok) { |
| 385 | const err = await resp.json().catch(() => ({ detail: '删除失败' })); |
| 386 | throw new Error(err.detail || '删除失败'); |
| 387 | } |
| 388 | return resp.json(); |
| 389 | } |
| 390 | |
| 391 | export async function saveSelections( |
| 392 | sessionId: string, |
| 393 | stage: string, |
| 394 | selections: Record<string, any>, |
| 395 | ): Promise<{ status: string }> { |
| 396 | const resp = await fetch(`/api/project/${sessionId}/artifact/${stage}`, { |
| 397 | method: 'PATCH', |
| 398 | headers: { 'Content-Type': 'application/json' }, |
| 399 | body: JSON.stringify(selections), |
| 400 | }); |
| 401 | if (!resp.ok) throw new Error('保存选项失败'); |
| 402 | return resp.json(); |
| 403 | } |
| 404 | |
| 405 | export async function continueWorkflow(sessionId: string): Promise<{ status: string; next_stage?: string }> { |
| 406 | const resp = await fetch(`/api/project/${sessionId}/continue`, { |
| 407 | method: 'POST', |
| 408 | }); |
| 409 | return resp.json(); |
| 410 | } |
| 411 | |
| 412 | export async function* parseStreamEvents(response: Response): AsyncGenerator<StreamEvent> { |
| 413 | if (!response.body) return; |
| 414 | const reader = response.body.getReader(); |
| 415 | const decoder = new TextDecoder(); |
| 416 | let buffer = ''; |
| 417 | while (true) { |
| 418 | const { done, value } = await reader.read(); |
| 419 | if (done) break; |
| 420 | buffer += decoder.decode(value, { stream: true }); |
| 421 | const lines = buffer.split('\n'); |
| 422 | buffer = lines.pop() || ''; // keep incomplete trailing line in buffer |
| 423 | for (const line of lines) { |
| 424 | if (!line.trim()) continue; |
| 425 | try { |
| 426 | const event: StreamEvent = JSON.parse(line); |
| 427 | if (event.type !== 'heartbeat') yield event; |
| 428 | } catch { /* skip malformed */ } |
| 429 | } |
| 430 | } |
| 431 | // Process any remaining data in buffer after stream ends |
| 432 | if (buffer.trim()) { |
| 433 | try { |
| 434 | const event: StreamEvent = JSON.parse(buffer); |
| 435 | if (event.type !== 'heartbeat') yield event; |
| 436 | } catch { /* skip malformed */ } |
| 437 | } |
| 438 | } |
| 439 |