返回 AiToEarn
agent.service.ts
根目录 / project / aitoearn-backend / apps / aitoearn-ai / src / core / agent / agent.service.ts
1 import { randomBytes } from 'node:crypto'
2 import {
3 McpServerConfig,
4 SpawnedProcess,
5 SpawnOptions,
6 } from '@anthropic-ai/claude-agent-sdk'
7 import { ContentBlockParam } from '@anthropic-ai/sdk/resources'
8 import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
9 import {
10 AppException,
11 getCodeMessage,
12 ResponseCode,
13 UserType,
14 } from '@yikart/common'
15 import {
16 ContentGenerationTaskRepository,
17 ContentGenerationTaskStatus,
18 Transactional,
19 } from '@yikart/mongodb'
20 import { RedisPubSubService } from '@yikart/redis'
21 import { Request, Response } from 'express'
22 import { Observable } from 'rxjs'
23 import { AGENT_TASK_ABORT_CHANNEL } from './agent.constants'
24 import {
25 CreateContentGenerationTaskDto,
26 CreateContentGenerationTaskRatingDto,
27 ListContentGenerationTaskDto,
28 UpdateContentGenerationTaskTitleDto,
29 } from './agent.dto'
30 import {
31 AgentMessageType,
32 AgentMessageVo,
33 ContentGenerationTaskChunkVo,
34 } from './agent.vo'
35 import { AgentRuntimeService } from './services/agent-runtime.service'
36
37 @Injectable()
38 export class AgentService implements OnModuleInit, OnModuleDestroy {
39 private readonly logger = new Logger(AgentService.name)
40
41 constructor(
42 private readonly contentGenerateRepository: ContentGenerationTaskRepository,
43 private readonly agentRuntimeService: AgentRuntimeService,
44 private readonly redisPubSubService: RedisPubSubService,
45 ) { }
46
47 /**
48 * 执行 Claude 查询
49 * @param systemPromptContent
50 * @param enhancedContent
51 * @param abortController
52 * @param options
53 * @param options.includePartialMessages
54 * @param options.sessionId
55 * @param options.model
56 * @param options.taskId
57 * @param options.availabilityOperation
58 * @param mcpServers
59 * @param spawnClaudeCodeProcess
60 * @returns
61 */
62 public claudeQuery(
63 systemPromptContent: ContentBlockParam[],
64 enhancedContent: ContentBlockParam[],
65 abortController: AbortController,
66 options: Parameters<AgentRuntimeService['claudeQuery']>[3],
67 mcpServers?: Record<string, McpServerConfig>,
68 spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess,
69 ) {
70 return this.agentRuntimeService.claudeQuery(
71 systemPromptContent,
72 enhancedContent,
73 abortController,
74 options,
75 mcpServers,
76 spawnClaudeCodeProcess,
77 )
78 }
79
80 /**
81 * 创建内容生成任务
82 * @param userId
83 * @param userType
84 * @param dto
85 * @param abortController
86 * @param req
87 * @param res
88 * @returns
89 */
90 createContentGenerationTask(
91 userId: string,
92 userType: UserType,
93 dto: CreateContentGenerationTaskDto,
94 abortController: AbortController,
95 req: Request,
96 res: Response,
97 ): Observable<ContentGenerationTaskChunkVo> {
98 return this.agentRuntimeService.createContentGenerationTask({
99 userId,
100 userType,
101 dto,
102 abortController,
103 req,
104 res,
105 })
106 }
107
108 async getTask(userId: string, taskId: string) {
109 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
110 if (!task) {
111 throw new AppException(ResponseCode.AgentTaskNotFound)
112 }
113 return {
114 ...task,
115 messages: task.messages as Array<AgentMessageVo>,
116 }
117 }
118
119 async getTaskMessages(userId: string, taskId: string, lastMessageId?: string) {
120 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
121 if (!task) {
122 throw new AppException(ResponseCode.AgentTaskNotFound)
123 }
124
125 const allMessages = (task.messages || []) as Array<AgentMessageVo>
126 let messages = allMessages
127
128 if (lastMessageId) {
129 const index = allMessages.findIndex(msg => 'uuid' in msg && msg.uuid === lastMessageId)
130 messages = index === -1 ? [] : allMessages.slice(index + 1)
131 }
132
133 return {
134 messages,
135 status: task.status,
136 }
137 }
138
139 async getTaskList(userId: string, params: ListContentGenerationTaskDto) {
140 return await this.contentGenerateRepository.getUserTasksWithPagination(userId, params)
141 }
142
143 async favoriteTask(userId: string, taskId: string) {
144 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
145 if (!task) {
146 throw new AppException(ResponseCode.AgentTaskNotFound)
147 }
148 await this.contentGenerateRepository.updateFavoriteById(taskId, new Date())
149 }
150
151 async unfavoriteTask(userId: string, taskId: string) {
152 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
153 if (!task) {
154 throw new AppException(ResponseCode.AgentTaskNotFound)
155 }
156 await this.contentGenerateRepository.updateFavoriteById(taskId, null)
157 }
158
159 async deleteTask(userId: string, taskId: string) {
160 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
161 if (!task) {
162 throw new AppException(ResponseCode.AgentTaskNotFound)
163 }
164 const success = await this.contentGenerateRepository.softDeleteTask(userId, taskId)
165 if (!success) {
166 throw new AppException(ResponseCode.AgentTaskNotFound)
167 }
168 }
169
170 async updateTask(userId: string, taskId: string, dto: UpdateContentGenerationTaskTitleDto) {
171 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
172 if (!task) {
173 throw new AppException(ResponseCode.AgentTaskNotFound)
174 }
175 await this.contentGenerateRepository.updateById(taskId, dto)
176 }
177
178 async createRating(userId: string, taskId: string, dto: CreateContentGenerationTaskRatingDto) {
179 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
180 if (!task) {
181 throw new AppException(ResponseCode.AgentTaskNotFound)
182 }
183
184 if (task.status !== ContentGenerationTaskStatus.Completed && task.status !== ContentGenerationTaskStatus.RequiresAction) {
185 throw new AppException(ResponseCode.AgentTaskStatusInvalid)
186 }
187
188 await this.contentGenerateRepository.updateById(taskId, {
189 rating: dto.rating,
190 ratingComment: dto.comment,
191 })
192 }
193
194 /**
195 * Create a public share token for a task. Only owner can create a share.
196 * ttlSeconds optional - default 7 days.
197 * Returns { token: string, expiresAt: Date, urlPath: string }
198 */
199 async createPublicShare(userId: string, taskId: string, ttlSeconds?: number) {
200 const task = await this.contentGenerateRepository.getUserTask(userId, taskId)
201 if (!task) {
202 throw new AppException(ResponseCode.AgentTaskNotFound)
203 }
204
205 const ttl = typeof ttlSeconds === 'number' ? ttlSeconds : 7 * 24 * 3600
206 const expiresAt = new Date(Date.now() + ttl * 1000)
207
208 const token = randomBytes(16).toString('hex')
209
210 await this.contentGenerateRepository.updateById(taskId, {
211 publicShareToken: token,
212 publicShareExpiresAt: expiresAt,
213 })
214
215 return {
216 token,
217 expiresAt,
218 }
219 }
220
221 /**
222 * Retrieve a task by public share token. Token must exist and not be expired.
223 * Returns sanitized task object (no sessionId).
224 */
225 async getTaskByShareToken(token: string) {
226 if (!token) {
227 throw new AppException(ResponseCode.AgentTaskNotFound)
228 }
229
230 const task = await this.contentGenerateRepository.findByPublicShareToken(token)
231 if (!task) {
232 throw new AppException(ResponseCode.AgentTaskNotFound)
233 }
234
235 if (task.publicShareExpiresAt && task.publicShareExpiresAt.getTime() < Date.now()) {
236 throw new AppException(ResponseCode.AgentTaskNotFound)
237 }
238
239 return {
240 ...task,
241 messages: task.messages as Array<AgentMessageVo>,
242 }
243 }
244
245 /**
246 * Forward a task to another user by copying its messages and metadata.
247 * Only the owner of the task can forward it.
248 * Returns the new task id object: { id: string }
249 */
250 async forwardTask(userId: string, taskId: string, targetUserId: string) {
251 const originalTask = await this.contentGenerateRepository.getUserTask(userId, taskId)
252 if (!originalTask) {
253 throw new AppException(ResponseCode.AgentTaskNotFound)
254 }
255
256 const newTask = await this.contentGenerateRepository.create({
257 userId: targetUserId,
258 title: originalTask.title ? `Fwd: ${originalTask.title}` : undefined,
259 messages: originalTask.messages || [],
260 status: originalTask.status || ContentGenerationTaskStatus.Completed,
261 })
262
263 return { id: newTask.id }
264 }
265
266 /**
267 * 兜底机制:将超时的 running 任务更新为 error 状态
268 * @param timeoutMs 超时时间(毫秒),默认 30 分钟
269 */
270 @Transactional()
271 async recoverTimeoutRunningTasks(timeoutMs: number = 30 * 60 * 1000) {
272 const timeoutTasks = await this.contentGenerateRepository.listTimeoutRunningTasks(timeoutMs)
273
274 if (timeoutTasks.length === 0) {
275 this.logger.debug('No timeout running tasks found')
276 return { updatedCount: 0 }
277 }
278
279 const taskIds = timeoutTasks.map(task => task.id)
280 this.logger.warn(`Found ${taskIds.length} timeout running tasks: ${taskIds.join(', ')}`)
281
282 const result = await this.contentGenerateRepository.batchUpdateStatus(taskIds, ContentGenerationTaskStatus.Error)
283
284 for (const task of timeoutTasks) {
285 const errorMessage = {
286 type: AgentMessageType.Error,
287 code: ResponseCode.AgentTaskTimeout,
288 message: getCodeMessage(ResponseCode.AgentTaskTimeout),
289 timestamp: Date.now(),
290 }
291 await this.contentGenerateRepository.updateMessage(task.id, errorMessage)
292 }
293
294 this.logger.debug(`Updated ${result.modifiedCount} timeout running tasks to error status`)
295
296 return { updatedCount: result.modifiedCount }
297 }
298
299 async onModuleInit() {
300 this.redisPubSubService.on(AGENT_TASK_ABORT_CHANNEL, (taskId: string) => {
301 this.agentRuntimeService.abortTask(taskId)
302 })
303 }
304
305 async onModuleDestroy() {
306 this.logger.debug('Agent service is shutting down, wait running tasks')
307 await this.agentRuntimeService.waitForRunningTasks()
308 }
309 }
310
310 lines TYPESCRIPT