返回 AiToEarn
useChatState.ts
根目录 / project / aitoearn-web / src / app / [lng] / chat / [taskId] / hooks / useChatState.ts
1 import type { TaskDetail, TaskMessage } from '@/api/ai/ai.types'
2 import type { IDisplayMessage, IWorkflowStep } from '@/store/agent'
3 import { useCallback, useEffect, useRef, useState } from 'react'
4 import { useShallow } from 'zustand/react/shallow'
5 import { agentApi } from '@/api/ai/ai.api'
6 import { useAgentStore } from '@/store/agent'
7 import { getDefaultTaskData } from '@/store/agent/agent.state'
8 import { useUserStore } from '@/store/user'
9 import { toast } from '@/utils/ui/toast'
10 import { convertMessages, isTaskCompleted } from '../utils'
11 import { useTaskPolling } from './useTaskPolling'
12
13 export interface IChatStateOptions {
14 /** 任务 ID */
15 taskId: string
16 /** 翻译函数 */
17 t: (key: string) => string
18 }
19
20 export interface IChatStateReturn {
21 /** 任务详情 */
22 task: TaskDetail | null
23 /** 当前显示的消息列表 */
24 displayMessages: IDisplayMessage[]
25 /** 工作流步骤(仅实时生成时有效) */
26 workflowSteps: IWorkflowStep[]
27 /** 是否正在加载 */
28 isLoading: boolean
29 /** 是否正在生成 */
30 isGenerating: boolean
31 /** 进度百分比 */
32 progress: number
33 /** 是否为活跃任务 */
34 isActiveTask: boolean
35 /** 更新本地消息(供子组件使用) */
36 setLocalMessages: React.Dispatch<React.SetStateAction<IDisplayMessage[]>>
37 /** 设置本地生成状态 */
38 setLocalIsGenerating: React.Dispatch<React.SetStateAction<boolean>>
39 /** 更新任务标题 */
40 updateTaskTitle: (newTitle: string) => void
41 }
42
43 /**
44 * 聊天状态管理 Hook
45 */
46 export function useChatState(options: IChatStateOptions): IChatStateReturn {
47 const { taskId, t } = options
48
49 // 全局 Store 状态 - 获取任务级数据
50 const { currentTaskId, taskMessages, setMessages, debugFiles } = useAgentStore(
51 useShallow(state => ({
52 currentTaskId: state.currentTaskId,
53 taskMessages: state.taskMessages,
54 setMessages: state.setMessages,
55 debugFiles: state.debugFiles,
56 })),
57 )
58
59 // 判断是否处于 debug 模式
60 const isDebugMode = debugFiles.length > 0
61
62 // 获取当前任务的数据(按 taskId 隔离)
63 const currentTaskData = taskMessages[taskId] || getDefaultTaskData()
64 const storeMessages = currentTaskData.messages
65 const storeWorkflowSteps = currentTaskData.workflowSteps
66 const storeIsGenerating = currentTaskData.isGenerating
67 const storeProgress = currentTaskData.progress
68
69 // 获取 Credits 余额
70 const fetchCreditsBalance = useUserStore(state => state.fetchCreditsBalance)
71
72 // 判断是否为活跃任务
73 const isActiveTask = currentTaskId === taskId
74 const isRealtimeGenerating = isActiveTask && storeIsGenerating
75
76 // 本地状态
77 const [task, setTask] = useState<TaskDetail | null>(null)
78 const [localMessages, setLocalMessages] = useState<IDisplayMessage[]>([])
79 const [isLoading, setIsLoading] = useState(true)
80 const [localIsGenerating, setLocalIsGenerating] = useState(false)
81
82 // Refs
83 const hasLoadedRef = useRef(false)
84 const rawMessagesRef = useRef<TaskMessage[]>([])
85
86 // 当 taskId 变化时,重置与任务相关的本地状态,确保不会错误地使用上一次任务的缓存
87 useEffect(() => {
88 // 重置已加载标记,强制重新从 API 拉取数据
89 hasLoadedRef.current = false
90 // 清空上一次的原始消息,避免后续基于旧数据的短路逻辑
91 rawMessagesRef.current = []
92 // 清空本地 task 与消息状态,显示 loading,等待新的加载逻辑触发
93 setTask(null)
94 setLocalMessages([])
95 setIsLoading(true)
96 // 注意:不主动调用 startPolling,这里只做重置,后续 loadTask 会根据新任务情况自行决定是否启动轮询
97 }, [taskId])
98
99 // 注意:由于现在消息是按 taskId 隔离存储的,不再需要复杂的护栏清理逻辑
100 // 每个任务的消息独立存储在 taskMessages[taskId] 中,不会互相干扰
101
102 // 轮询 Hook
103 const { isPolling, startPolling } = useTaskPolling({
104 taskId,
105 isActiveTask,
106 // 轮询间隔(ms)
107 pollingInterval: 1500,
108 getCurrentRawMessages: useCallback(() => rawMessagesRef.current, []),
109 onMessagesUpdate: useCallback(
110 (messages, rawMessages) => {
111 rawMessagesRef.current = rawMessages
112 setLocalMessages(messages)
113
114 // 防御性检查:只有当新消息数量 >= 当前消息数量时才更新 store
115 // 防止轮询返回的不完整数据覆盖 SSE 实时追加的消息
116 const currentMessages = taskMessages[taskId]?.messages || []
117 if (messages.length >= currentMessages.length) {
118 setMessages(messages, taskId)
119 }
120 else {
121 console.warn(
122 '[ChatState] Skipping setMessages: new messages count is less than current',
123 {
124 current: currentMessages.length,
125 new: messages.length,
126 },
127 )
128 }
129 },
130 [setMessages, taskId, taskMessages],
131 ),
132 onTaskUpdate: useCallback((taskData: TaskDetail) => {
133 setTask(taskData)
134 }, []),
135 onTaskStatusChange: useCallback((status: string) => {
136 if (status === 'aborted') {
137 // Task aborted, clearing generating state
138 // 任务被中止时,停止本地生成状态
139 setLocalIsGenerating(false)
140 }
141 }, []),
142 })
143
144 /**
145 * 加载任务详情
146 */
147 useEffect(() => {
148 // 如果是 "new" 任务,不加载历史数据,等待创建
149 if (taskId === 'new') {
150 setIsLoading(false)
151 return
152 }
153
154 // 如果已经加载过,不再重复加载
155 if (hasLoadedRef.current) {
156 setIsLoading(false)
157 return
158 }
159
160 // 如果 Store 中已有该任务的消息,优先使用(支持任务缓存)
161 if (storeMessages.length > 0) {
162 setIsLoading(false)
163 hasLoadedRef.current = true
164 return
165 }
166
167 const loadTask = async () => {
168 if (!taskId)
169 return
170
171 setIsLoading(true)
172 try {
173 const result = await agentApi.getTaskDetail(taskId)
174 if (!result) {
175 toast.error(t('message.error'))
176 return
177 }
178 if (result.code === 0 && result.data) {
179 setTask(result.data)
180
181 if (result.data.messages) {
182 rawMessagesRef.current = result.data.messages
183 const converted = convertMessages(result.data.messages)
184 setLocalMessages(converted)
185 setMessages(converted, taskId)
186
187 // 检测任务是否完成,如果未完成则启动轮询
188 if (!isTaskCompleted(result.data.messages, result.data)) {
189 // Task not completed, starting polling
190 startPolling()
191 }
192 }
193
194 // 获取到 result 后,刷新 Credits 余额
195 fetchCreditsBalance()
196
197 hasLoadedRef.current = true
198 }
199 else {
200 toast.error(result.message || t('message.error'))
201 }
202 }
203 catch (error) {
204 console.error('Load task detail failed:', error)
205 toast.error(t('message.error'))
206 }
207 finally {
208 setIsLoading(false)
209 }
210 }
211
212 loadTask()
213 }, [taskId, storeMessages.length, t, setMessages, startPolling, fetchCreditsBalance])
214
215 // 计算最终显示的消息和状态
216 // 优先使用 store 中的消息(支持任务缓存和实时更新)
217 // debug 模式下强制使用 store 的消息,用于调试回放
218 // taskId='new' 时,使用 currentTaskId 对应的消息(临时任务的消息)
219 // 这样可以在创建任务后立即显示用户消息和 AI 思考状态
220 const displayMessages = (() => {
221 if (taskId === 'new') {
222 // 获取当前活跃任务的消息(临时任务 temp-xxx)
223 const currentData = taskMessages[currentTaskId]
224 return currentData?.messages || []
225 }
226 return storeMessages.length > 0 || isActiveTask || isDebugMode ? storeMessages : localMessages
227 })()
228 const isGenerating = isRealtimeGenerating || localIsGenerating || isPolling
229 // taskId='new' 时,工作流步骤存储在 currentTaskId(临时任务)中
230 const workflowSteps: IWorkflowStep[] = (() => {
231 if (taskId === 'new') {
232 const currentData = taskMessages[currentTaskId]
233 return currentData?.workflowSteps || []
234 }
235 return isActiveTask || isDebugMode ? storeWorkflowSteps : []
236 })()
237
238 /**
239 * 更新任务标题(用于编辑标题成功后更新本地状态)
240 */
241 const updateTaskTitle = useCallback((newTitle: string) => {
242 setTask(prev => (prev ? { ...prev, title: newTitle } : prev))
243 }, [])
244
245 return {
246 task,
247 displayMessages,
248 workflowSteps,
249 isLoading,
250 isGenerating,
251 progress: storeProgress,
252 isActiveTask,
253 setLocalMessages,
254 setLocalIsGenerating,
255 updateTaskTitle,
256 }
257 }
258
258 lines TYPESCRIPT