| 1 | import type { AgentTaskRatingResponse, AgentTaskShareVo, AiChatStreamParams, AiLogListParams, AiPaginationParams, ChatModel, CreateAgentTaskParams, CreateDraftFromVideoUrlDto, CreateDraftFromVideoUrlVo, CreateDraftGenerationVo, CreateImageTextDraftGenerationParams, CreateTaskResponse, CreateVideoDraftGenerationParams, DraftGenerationPricingVo, DraftGenerationStats, DraftGenerationTask, DraftGenerationTaskListVo, GetTaskListParams, SSEMessage, SubmitAgentTaskRatingPayload, TaskDetail, TaskListResponse, TaskMessagesVo, VideoGenerationHistoryListVo } from './ai.types' |
| 2 | import type { AssetListVo } from '@/types/agent-asset' |
| 3 | import { fetchEventSource } from '@microsoft/fetch-event-source' |
| 4 | import { useAgentStore } from '@/store/agent' |
| 5 | import { useUserStore } from '@/store/user' |
| 6 | import http from '@/utils/request' |
| 7 | |
| 8 | /** AI 生成任务、草稿生成、模型配置与任务消息接口。 */ |
| 9 | export const agentApi = { |
| 10 | /** |
| 11 | * 创建AI生成任务并通过 SSE 接收实时消息 |
| 12 | * @param params |
| 13 | * @param onMessage SSE 消息回调 |
| 14 | * @param onError 错误回调 |
| 15 | * @param onDone 完成回调 |
| 16 | * @returns 返回一个 abort 函数,用于中断 SSE 连接 |
| 17 | */ |
| 18 | async createTaskWithSSE( |
| 19 | params: CreateAgentTaskParams, |
| 20 | onMessage: (message: SSEMessage) => void, |
| 21 | onError: (error: Error) => void, |
| 22 | onDone: (sessionId?: string) => void, |
| 23 | ): Promise<() => void> { |
| 24 | const apiUrl = process.env.NEXT_PUBLIC_API_URL |
| 25 | const url = `${apiUrl}/agent/tasks` |
| 26 | |
| 27 | let sessionId: string | undefined |
| 28 | const abortController = new AbortController() |
| 29 | // 用于消息去重,防止重复处理 |
| 30 | const processedMessageIds = new Set<string>() |
| 31 | // 标记是否已经完成,防止重复调用 onDone |
| 32 | let isCompleted = false |
| 33 | |
| 34 | // 返回 abort 函数 |
| 35 | const abort = () => { |
| 36 | abortController.abort() |
| 37 | } |
| 38 | |
| 39 | // Debug 模式拦截: 如果处于 debug 模式且还有文件可用,使用本地文件回放 |
| 40 | try { |
| 41 | const store = useAgentStore.getState() |
| 42 | if (store.debugFiles.length > 0 && store.debugMessageIndex < store.debugFiles.length) { |
| 43 | const debugFilePath = useAgentStore.getState().consumeDebugFile() |
| 44 | |
| 45 | if (debugFilePath) { |
| 46 | let isAborted = false |
| 47 | const abort = () => { |
| 48 | isAborted = true |
| 49 | } |
| 50 | |
| 51 | ;(async () => { |
| 52 | try { |
| 53 | const resp = await fetch(debugFilePath) |
| 54 | if (!resp.ok) { |
| 55 | console.warn('[SSE] Debug replay: failed to fetch file:', debugFilePath) |
| 56 | onDone?.() |
| 57 | return |
| 58 | } |
| 59 | |
| 60 | const raw = await resp.text() |
| 61 | |
| 62 | // 解析 SSE 格式的数据块 |
| 63 | const blocks = raw |
| 64 | .split(/\r?\n\r?\n+/) |
| 65 | .map(b => b.trim()) |
| 66 | .filter(Boolean) |
| 67 | |
| 68 | for (let i = 0; i < blocks.length; i++) { |
| 69 | if (isAborted) |
| 70 | break |
| 71 | const block = blocks[i] |
| 72 | const dataLine = block.split(/\r?\n/).find(l => l.startsWith('data:')) |
| 73 | if (!dataLine) |
| 74 | continue |
| 75 | const jsonPart = dataLine.replace(/^data:\s*/, '') |
| 76 | try { |
| 77 | const data = JSON.parse(jsonPart) |
| 78 | onMessage(data as SSEMessage) |
| 79 | } |
| 80 | catch (e) { |
| 81 | console.warn('[SSE] Debug replay: failed to parse block', e) |
| 82 | } |
| 83 | // 模拟流式推送的小延迟 |
| 84 | await new Promise(r => setTimeout(r, 40)) |
| 85 | } |
| 86 | |
| 87 | if (!isAborted) { |
| 88 | onDone?.() |
| 89 | } |
| 90 | } |
| 91 | catch (e) { |
| 92 | console.error('[SSE] Debug replay error:', e) |
| 93 | onDone?.() |
| 94 | } |
| 95 | })() |
| 96 | |
| 97 | return abort |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | catch (e) { |
| 102 | console.warn('[SSE] Debug mode check failed', e) |
| 103 | } |
| 104 | |
| 105 | try { |
| 106 | // 获取语言设置 |
| 107 | const lng = useUserStore.getState().lang || 'en' |
| 108 | |
| 109 | await fetchEventSource(url, { |
| 110 | method: 'POST', |
| 111 | headers: { |
| 112 | 'Content-Type': 'application/json', |
| 113 | 'Authorization': `Bearer ${useUserStore.getState().token || ''}`, |
| 114 | 'Accept-Language': lng, |
| 115 | }, |
| 116 | body: JSON.stringify(params), |
| 117 | signal: abortController.signal, |
| 118 | openWhenHidden: true, |
| 119 | |
| 120 | // 当连接打开时 |
| 121 | async onopen(response) { |
| 122 | if (response.ok) { |
| 123 | return // 一切正常,继续处理消息 |
| 124 | } |
| 125 | |
| 126 | // 处理错误响应 |
| 127 | if (response.status >= 400 && response.status < 500 && response.status !== 429) { |
| 128 | // 客户端错误,不重试 |
| 129 | const errorText = await response.text() |
| 130 | console.error('[SSE] Client error:', response.status, errorText) |
| 131 | throw new Error(`HTTP ${response.status}: ${errorText}`) |
| 132 | } |
| 133 | else { |
| 134 | // 服务器错误或其他问题,不自动重试,直接抛出错误 |
| 135 | console.error('[SSE] Server error:', response.status) |
| 136 | throw new Error(`HTTP ${response.status}`) |
| 137 | } |
| 138 | }, |
| 139 | |
| 140 | // 当收到消息时 |
| 141 | onmessage(event) { |
| 142 | // 如果已完成,忽略后续消息 |
| 143 | if (isCompleted) { |
| 144 | return |
| 145 | } |
| 146 | |
| 147 | // 如果没有数据,跳过 |
| 148 | if (!event.data) { |
| 149 | return |
| 150 | } |
| 151 | |
| 152 | try { |
| 153 | const data = JSON.parse(event.data) |
| 154 | |
| 155 | // 消息去重:基于 uuid 或生成唯一标识 |
| 156 | const messageId |
| 157 | = data.uuid |
| 158 | || data.message?.uuid |
| 159 | || `${data.type}-${JSON.stringify(data).slice(0, 100)}` |
| 160 | if (processedMessageIds.has(messageId)) { |
| 161 | return |
| 162 | } |
| 163 | processedMessageIds.add(messageId) |
| 164 | |
| 165 | // 保存 sessionId |
| 166 | if (data.sessionId) { |
| 167 | sessionId = data.sessionId |
| 168 | } |
| 169 | |
| 170 | // 调用消息回调 |
| 171 | onMessage(data) |
| 172 | |
| 173 | // 如果收到结束信号,关闭连接 |
| 174 | if (data.type === 'done' || data.type === 'error') { |
| 175 | isCompleted = true |
| 176 | abortController.abort() |
| 177 | } |
| 178 | } |
| 179 | catch (error) { |
| 180 | console.error('[SSE] Failed to parse message:', event.data, error) |
| 181 | } |
| 182 | }, |
| 183 | |
| 184 | // 当连接关闭时 |
| 185 | onclose() { |
| 186 | if (!isCompleted) { |
| 187 | isCompleted = true |
| 188 | onDone(sessionId) |
| 189 | } |
| 190 | }, |
| 191 | |
| 192 | // 当发生错误时 |
| 193 | onerror(error) { |
| 194 | console.error('[SSE] Error occurred:', error) |
| 195 | |
| 196 | // 如果是手动中止,不抛出错误 |
| 197 | if (abortController.signal.aborted) { |
| 198 | return |
| 199 | } |
| 200 | |
| 201 | // 标记为已完成,防止重复处理 |
| 202 | if (!isCompleted) { |
| 203 | isCompleted = true |
| 204 | // 其他错误,调用错误回调 |
| 205 | onError(error instanceof Error ? error : new Error(String(error))) |
| 206 | } |
| 207 | |
| 208 | // 抛出错误以停止重试 |
| 209 | throw error |
| 210 | }, |
| 211 | }) |
| 212 | } |
| 213 | catch (error) { |
| 214 | console.error('[SSE] fetchEventSource failed:', error) |
| 215 | |
| 216 | // 如果不是手动中止的错误且未完成,调用错误回调 |
| 217 | if (!abortController.signal.aborted && !isCompleted) { |
| 218 | isCompleted = true |
| 219 | onError(error instanceof Error ? error : new Error(String(error))) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // 返回 abort 函数 |
| 224 | return abort |
| 225 | }, |
| 226 | |
| 227 | /** |
| 228 | * 创建AI生成任务(旧方法,保留兼容性) |
| 229 | * @param params |
| 230 | */ |
| 231 | async createTask(params: CreateAgentTaskParams) { |
| 232 | const res = await http.post<CreateTaskResponse>('agent/tasks', params) |
| 233 | return res |
| 234 | }, |
| 235 | |
| 236 | /** |
| 237 | * 获取任务详情 |
| 238 | * @param taskId 任务ID |
| 239 | */ |
| 240 | async getTaskDetail(taskId: string) { |
| 241 | const res = await http.get<TaskDetail>(`agent/tasks/${taskId}`) |
| 242 | return res |
| 243 | }, |
| 244 | |
| 245 | /** |
| 246 | * 获取任务消息(增量) |
| 247 | * @param taskId 任务ID |
| 248 | * @param lastMessageId 上次获取的最后一条消息 UUID,可选 |
| 249 | */ |
| 250 | async getTaskMessages(taskId: string, lastMessageId?: string) { |
| 251 | const params = lastMessageId ? { lastMessageId } : undefined |
| 252 | const res = await http.get<TaskMessagesVo>(`agent/tasks/${taskId}/messages`, params) |
| 253 | return res |
| 254 | }, |
| 255 | |
| 256 | /** |
| 257 | * 获取任务评分 |
| 258 | * @param taskId 任务ID |
| 259 | */ |
| 260 | async getTaskRating(taskId: string) { |
| 261 | const res = await http.get<AgentTaskRatingResponse>( |
| 262 | `agent/tasks/${taskId}/rating`, |
| 263 | ) |
| 264 | return res |
| 265 | }, |
| 266 | |
| 267 | /** |
| 268 | * 提交或更新任务评分 |
| 269 | * @param taskId 任务ID |
| 270 | * @param payload 评分内容 |
| 271 | * @param payload.rating 评分值 |
| 272 | * @param payload.comment 评分备注 |
| 273 | */ |
| 274 | async submitTaskRating(taskId: string, payload: SubmitAgentTaskRatingPayload) { |
| 275 | const res = await http.post(`agent/tasks/${taskId}/rating`, payload) |
| 276 | return res |
| 277 | }, |
| 278 | |
| 279 | /** |
| 280 | * 停止/取消任务 |
| 281 | * @param taskId 任务ID |
| 282 | */ |
| 283 | async stopTask(taskId: string) { |
| 284 | const res = await http.delete(`agent/tasks/${taskId}`) |
| 285 | return res |
| 286 | }, |
| 287 | |
| 288 | /** |
| 289 | * 中断内容生成任务 |
| 290 | * @param taskId 任务ID |
| 291 | */ |
| 292 | async abortTask(taskId: string) { |
| 293 | const res = await http.post(`agent/tasks/${taskId}/abort`) |
| 294 | return res |
| 295 | }, |
| 296 | |
| 297 | /** |
| 298 | * 获取任务列表 |
| 299 | * @param params 查询参数 |
| 300 | */ |
| 301 | async getTaskList(params: GetTaskListParams = {}) { |
| 302 | const { page = 1, pageSize = 10, keyword, favoriteOnly } = params |
| 303 | const queryParams: Record<string, string | number | boolean> = { page, pageSize } |
| 304 | |
| 305 | // 添加搜索关键词(截取前100字符) |
| 306 | if (keyword?.trim()) { |
| 307 | queryParams.keyword = keyword.trim().slice(0, 100) |
| 308 | } |
| 309 | // 添加收藏筛选 |
| 310 | if (favoriteOnly) { |
| 311 | queryParams.favoriteOnly = true |
| 312 | } |
| 313 | |
| 314 | const res = await http.get<TaskListResponse>('agent/tasks', queryParams) |
| 315 | return res |
| 316 | }, |
| 317 | |
| 318 | /** |
| 319 | * 删除任务 |
| 320 | * @param taskId 任务ID |
| 321 | */ |
| 322 | async deleteTask(taskId: string) { |
| 323 | const res = await http.delete(`agent/tasks/${taskId}`) |
| 324 | return res |
| 325 | }, |
| 326 | |
| 327 | /** |
| 328 | * 更新任务标题 |
| 329 | * @param taskId 任务ID |
| 330 | * @param title 新标题 |
| 331 | */ |
| 332 | async updateTaskTitle(taskId: string, title: string) { |
| 333 | const res = await http.patch(`agent/tasks/${taskId}`, { title }) |
| 334 | return res |
| 335 | }, |
| 336 | /** |
| 337 | * 为任务创建或更新评分 |
| 338 | * @param taskId 任务ID |
| 339 | * @param rating 评分值(1-5) |
| 340 | * @param comment 可选评论 |
| 341 | */ |
| 342 | async createRating(taskId: string, rating: number, comment?: string) { |
| 343 | const res = await http.post(`agent/tasks/${taskId}/rating`, { rating, comment }) |
| 344 | return res |
| 345 | }, |
| 346 | /** |
| 347 | * Create a public share token for a task |
| 348 | * @param taskId |
| 349 | * @param ttlSeconds optional validity in seconds |
| 350 | */ |
| 351 | async createPublicShare(taskId: string, ttlSeconds?: number) { |
| 352 | const body = typeof ttlSeconds === 'number' ? { ttlSeconds } : undefined |
| 353 | const res = await http.post<AgentTaskShareVo>( |
| 354 | `agent/tasks/${taskId}/share`, |
| 355 | body, |
| 356 | ) |
| 357 | return res |
| 358 | }, |
| 359 | |
| 360 | /** |
| 361 | * Get task by public share token (no authentication required) |
| 362 | * @param token Share token |
| 363 | */ |
| 364 | async getTaskByShareToken(token: string) { |
| 365 | const res = await http.get<TaskDetail>(`agent/tasks/shared/${token}`) |
| 366 | return res |
| 367 | }, |
| 368 | |
| 369 | /** |
| 370 | * 收藏任务 |
| 371 | * @param taskId 任务ID |
| 372 | */ |
| 373 | async favoriteTask(taskId: string) { |
| 374 | const res = await http.post(`agent/tasks/${taskId}/favorite`) |
| 375 | return res |
| 376 | }, |
| 377 | |
| 378 | /** |
| 379 | * 取消收藏任务 |
| 380 | * @param taskId 任务ID |
| 381 | */ |
| 382 | async unfavoriteTask(taskId: string) { |
| 383 | const res = await http.delete(`agent/tasks/${taskId}/favorite`) |
| 384 | return res |
| 385 | }, |
| 386 | } |
| 387 | |
| 388 | // Source: ai.ts |
| 389 | /** |
| 390 | * Get Chat Model Parameters |
| 391 | */ |
| 392 | export function getChatModels(scene?: 'comment' | 'web', silent?: boolean) { |
| 393 | return http.get<ChatModel[]>(`ai/models/chat?scene=${scene || 'web'}`, undefined, silent) |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * List Video Tasks |
| 398 | */ |
| 399 | export function getVideoGenerations(params?: AiPaginationParams) { |
| 400 | return http.get<VideoGenerationHistoryListVo>('ai/video/generations', params) |
| 401 | } |
| 402 | |
| 403 | /** |
| 404 | * 获取用户 AI 活动日志 |
| 405 | */ |
| 406 | export function getLogs(params?: AiLogListParams) { |
| 407 | return http.get('ai/logs', params) |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * AI聊天接口 - 支持流式和非流式响应 |
| 412 | */ |
| 413 | export async function aiChatStream(data: AiChatStreamParams) { |
| 414 | const token = useUserStore.getState().token |
| 415 | const lang = useUserStore.getState().lang |
| 416 | |
| 417 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/ai/chat`, { |
| 418 | method: 'POST', |
| 419 | headers: { |
| 420 | 'Content-Type': 'application/json', |
| 421 | 'Authorization': token ? `Bearer ${token}` : '', |
| 422 | 'Accept-Language': lang || 'en', |
| 423 | }, |
| 424 | body: JSON.stringify({ |
| 425 | stream: false, // 使用非流式响应 |
| 426 | model: 'gpt-5.1-all', |
| 427 | temperature: 1, |
| 428 | presence_penalty: 0, |
| 429 | frequency_penalty: 0, |
| 430 | top_p: 1, |
| 431 | max_tokens: 8000, // 增加到8000以支持更长的响应(包括base64图片) |
| 432 | ...data, |
| 433 | }), |
| 434 | }) |
| 435 | |
| 436 | if (!response.ok) { |
| 437 | throw new Error(`HTTP error! status: ${response.status}`) |
| 438 | } |
| 439 | |
| 440 | return response |
| 441 | } |
| 442 | |
| 443 | /** |
| 444 | * 获取 Agent 生成的素材列表 |
| 445 | * @param params - 分页参数 |
| 446 | * @param params.page - 页码 |
| 447 | * @param params.pageSize - 每页数量 |
| 448 | * @returns 素材列表 |
| 449 | */ |
| 450 | export function getAgentAssets(params?: AiPaginationParams) { |
| 451 | return http.get<AssetListVo>('ai/assets', params) |
| 452 | } |
| 453 | |
| 454 | /** 创建 AI 批量生成草稿任务 */ |
| 455 | export function apiCreateDraftGeneration(data: CreateVideoDraftGenerationParams) { |
| 456 | return http.post<CreateDraftGenerationVo>('ai/draft-generation/v2', data) |
| 457 | } |
| 458 | |
| 459 | /** 创建 AI 图文草稿生成任务 */ |
| 460 | export function apiCreateImageTextDraft(data: CreateImageTextDraftGenerationParams) { |
| 461 | return http.post<CreateDraftGenerationVo>('ai/draft-generation/image-text', data) |
| 462 | } |
| 463 | |
| 464 | /** 获取图片模型定价信息 */ |
| 465 | export function apiGetDraftGenerationPricing() { |
| 466 | return http.get<DraftGenerationPricingVo>('ai/draft-generation/pricing') |
| 467 | } |
| 468 | |
| 469 | /** 根据视频 URL 生成草稿 */ |
| 470 | export function apiCreateDraftFromVideoUrl(data: CreateDraftFromVideoUrlDto) { |
| 471 | return http.post<CreateDraftFromVideoUrlVo>('ai/draft-generation/from-video-url', data) |
| 472 | } |
| 473 | |
| 474 | /** |
| 475 | * 获取生成中任务数量统计(轮询用,静默模式不弹错误提示) |
| 476 | */ |
| 477 | export function apiGetDraftGenerationStats() { |
| 478 | return http.get<DraftGenerationStats>('ai/draft-generation/stats', undefined, true) |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * 根据任务 ID 批量查询生成任务状态(轮询用,静默模式不弹错误提示) |
| 483 | */ |
| 484 | export function apiQueryDraftGenerationTasks(taskIds: string[]) { |
| 485 | return http.post<DraftGenerationTask[]>('ai/draft-generation/query', { taskIds }, true) |
| 486 | } |
| 487 | |
| 488 | /** |
| 489 | * 获取生成任务列表(分页) |
| 490 | * @param page 页码 |
| 491 | * @param pageSize 每页数量 |
| 492 | */ |
| 493 | export function apiGetDraftGenerationList(page: number = 1, pageSize: number = 10) { |
| 494 | return http.get<DraftGenerationTaskListVo>( |
| 495 | 'ai/draft-generation/', |
| 496 | { page, pageSize }, |
| 497 | ) |
| 498 | } |
| 499 |