返回 VideoClaw
workflowApi.ts
根目录 / video-claw / video-claw / frontend / lib / workflowApi.ts
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
439 lines TYPESCRIPT