返回 AiToEarn
page.tsx
根目录 / project / aitoearn-web / src / app / [lng] / chat / [taskId] / page.tsx
1 /**
2 * 对话详情页 - Chat Detail
3 * 功能:支持实时模式(从 HomeChat 跳转)和历史模式(刷新或从任务列表进入)
4 * 工作流状态实时显示在对应消息上
5 */
6 'use client'
7
8 import { useParams, useRouter } from 'next/navigation'
9 import { useCallback, useEffect, useState } from 'react'
10 import { agentApi } from '@/api/ai/ai.api'
11 import { useTransClient } from '@/app/i18n/client'
12 import { ChatInput } from '@/components/Chat/ChatInput'
13 import EditTitleModal from '@/components/common/EditTitleModal'
14 import { PublishDetailModal } from '@/components/Plugin/PublishDetailModal'
15 import { useDocumentTitle, useMediaUpload } from '@/hooks'
16 import { useAgentStore } from '@/store/agent'
17 import { usePluginStore } from '@/store/plugin'
18 import { toast } from '@/utils/ui/toast'
19
20 // 页面私有组件
21 import { ChatHeader, ChatLoadingSkeleton, ChatMessageList } from './components'
22 // 页面私有 hooks
23 import { useChatState, useScrollControl } from './hooks'
24
25 export default function ChatDetailPage() {
26 const { t } = useTransClient('chat')
27 const { t: tHome } = useTransClient('home')
28 const router = useRouter()
29 const params = useParams()
30 const taskId = params.taskId as string
31 const lng = params.lng as string
32
33 // Store 方法
34 const { createTask, continueTask, stopTask, setActionContext, consumePendingTask }
35 = useAgentStore()
36
37 // 聊天状态管理
38 const {
39 task,
40 displayMessages,
41 workflowSteps,
42 isLoading,
43 isGenerating,
44 progress,
45 isActiveTask,
46 setLocalIsGenerating,
47 updateTaskTitle,
48 } = useChatState({
49 taskId,
50 t: t as (key: string) => string,
51 })
52
53 // 中断状态 - 用于强制隐藏工作流步骤
54 const [isInterrupted, setIsInterrupted] = useState(false)
55
56 // 本地评分状态 - 用于评分成功后立即更新 UI
57 const [localRating, setLocalRating] = useState<number | null | undefined>(undefined)
58
59 // 收藏状态(乐观更新)
60 const [isFavorited, setIsFavorited] = useState(false)
61
62 // 编辑标题状态
63 const [editTitleOpen, setEditTitleOpen] = useState(false)
64
65 // 发布详情弹框状态
66 const [publishDetailVisible, setPublishDetailVisible] = useState(false)
67 const [currentPublishTaskId, setCurrentPublishTaskId] = useState<string | undefined>(undefined)
68 // 跟踪用户是否手动关闭了弹窗
69 const [userClosedModal, setUserClosedModal] = useState(false)
70
71 // 滚动控制
72 const {
73 containerRef,
74 bottomRef,
75 isNearBottom,
76 showScrollButton,
77 scrollToBottom,
78 handleScroll,
79 onContentReady,
80 } = useScrollControl()
81
82 // 输入状态
83 const [inputValue, setInputValue] = useState('')
84
85 // 媒体上传
86 const {
87 medias,
88 setMedias,
89 isUploading,
90 handleMediasChange,
91 handleMediaRemove,
92 handleMediaUpdate,
93 clearMedias,
94 } = useMediaUpload({
95 onError: () => toast.error(t('media.uploadFailed')),
96 })
97
98 // 监听发布任务创建
99 const publishTasks = usePluginStore(state => state.publishTasks)
100
101 // 动态更新页面标题
102 useDocumentTitle(task?.title, t('task.newChat'))
103
104 // 从 task 初始化收藏状态
105 useEffect(() => {
106 if (task) {
107 setIsFavorited(!!task.favoritedAt)
108 }
109 }, [task])
110
111 // 监听发布任务创建,显示发布详情弹框
112 useEffect(() => {
113 if (publishTasks.length > 0) {
114 // 获取最新的发布任务(通常是刚创建的)
115 const latestTask = publishTasks[0]
116 if (latestTask && !publishDetailVisible && !userClosedModal) {
117 setCurrentPublishTaskId(latestTask.id)
118 setPublishDetailVisible(true)
119 // 重置用户关闭状态,因为现在有新任务了
120 setUserClosedModal(false)
121 }
122 }
123 }, [publishTasks, publishDetailVisible, userClosedModal])
124
125 /**
126 * 设置 Action 上下文(用于处理任务结果的 action)
127 */
128 useEffect(() => {
129 setActionContext({
130 router,
131 lng,
132 t: tHome,
133 })
134 }, [router, lng, tHome, setActionContext])
135
136 /**
137 * 处理新任务:当 taskId 为 "new" 时,从 store 获取待处理任务并发起请求
138 */
139 useEffect(() => {
140 if (taskId !== 'new')
141 return
142
143 const pendingTask = consumePendingTask()
144 if (!pendingTask) {
145 // 没有待处理任务,返回首页
146 router.replace(`/${lng}`)
147 return
148 }
149
150 // 发起任务创建
151 const startTask = async () => {
152 setIsInterrupted(false)
153 setLocalIsGenerating(true)
154 try {
155 await createTask({
156 prompt: pendingTask.prompt,
157 medias: pendingTask.medias,
158 t: t as (key: string) => string,
159 onTaskIdReady: (newTaskId) => {
160 // 使用 replace 替换 URL,不添加历史记录
161 router.replace(`/${lng}/chat/${newTaskId}`)
162 },
163 })
164 }
165 catch (error: any) {
166 console.error('[ChatPage] Create task failed:', error)
167 toast.error(error.message || t('message.error'))
168 // 出错时返回首页
169 router.replace(`/${lng}`)
170 }
171 }
172
173 startTask()
174 }, [taskId, lng, router, consumePendingTask, createTask, t, setLocalIsGenerating])
175
176 /**
177 * 首次加载完成后,强制滚动到底部
178 */
179 useEffect(() => {
180 // 当数据加载完成且有消息时,确保滚动到底部
181 if (!isLoading && displayMessages.length > 0) {
182 onContentReady()
183 }
184 }, [isLoading, displayMessages.length, onContentReady])
185
186 /**
187 * 智能滚动:用户在底部附近时自动滚动
188 */
189 useEffect(() => {
190 if (isNearBottom) {
191 scrollToBottom()
192 }
193 }, [displayMessages, workflowSteps, isNearBottom, scrollToBottom])
194
195 /**
196 * 发送消息(继续对话)
197 */
198 const handleSend = useCallback(async () => {
199 if (!inputValue.trim() || isGenerating)
200 return
201
202 const currentPrompt = inputValue
203 const currentMedias = [...medias]
204
205 // 清空输入
206 setInputValue('')
207 clearMedias()
208 // 重置中断状态,开始新任务
209 setIsInterrupted(false)
210 setLocalIsGenerating(true)
211
212 // 强制滚动到底部
213 scrollToBottom(true)
214
215 try {
216 await continueTask({
217 prompt: currentPrompt,
218 medias: currentMedias,
219 t: t as (key: string) => string,
220 taskId,
221 })
222 }
223 catch (error: any) {
224 console.error('Continue task failed:', error)
225 toast.error(error.message || t('message.error'))
226 // 恢复输入
227 setInputValue(inputValue)
228 setMedias(currentMedias)
229 }
230 finally {
231 setLocalIsGenerating(false)
232 }
233 }, [
234 inputValue,
235 medias,
236 isGenerating,
237 taskId,
238 t,
239 continueTask,
240 clearMedias,
241 setMedias,
242 scrollToBottom,
243 setLocalIsGenerating,
244 ])
245
246 /**
247 * 停止生成
248 */
249 const handleStop = useCallback(async () => {
250 try {
251 // 调用后端 API 中断任务
252 await agentApi.abortTask(taskId)
253 // 显示停止成功的提示
254 toast.info(tHome('aiGeneration.taskStopped'))
255 }
256 catch (error: any) {
257 console.error('[ChatPage] Failed to abort task:', error)
258 toast.error(t('message.error'))
259 }
260 finally {
261 // 无论 API 调用是否成功,都停止本地状态
262 // 立即设置中断状态,强制隐藏工作流步骤
263 setIsInterrupted(true)
264 // 立即设置本地生成状态为 false,确保 UI 立即响应
265 setLocalIsGenerating(false)
266 stopTask()
267 }
268 }, [taskId, t, tHome, stopTask, setLocalIsGenerating])
269
270 /**
271 * 返回首页
272 */
273 const handleBack = useCallback(() => {
274 // 中断当前正在进行的任务,防止竞态条件
275 if (isGenerating) {
276 stopTask()
277 }
278
279 // If there's a previous entry in the history stack, go back.
280 // Otherwise, navigate to the root homepage.
281 if (typeof window !== 'undefined' && window.history.length > 1) {
282 router.back()
283 }
284 else {
285 router.push('/')
286 }
287 }, [router, isGenerating, stopTask])
288
289 /**
290 * 收藏切换 - 乐观更新
291 */
292 const handleFavoriteToggle = useCallback(async () => {
293 const newValue = !isFavorited
294 setIsFavorited(newValue) // 先更新 UI
295
296 try {
297 if (newValue) {
298 await agentApi.favoriteTask(taskId)
299 }
300 else {
301 await agentApi.unfavoriteTask(taskId)
302 }
303 }
304 catch {
305 setIsFavorited(!newValue) // 失败回滚
306 toast.error(t('message.error'))
307 }
308 }, [isFavorited, taskId, t])
309
310 /**
311 * 保存标题 - 乐观更新
312 */
313 const handleSaveTitle = useCallback(
314 async (newTitle: string) => {
315 const result = await agentApi.updateTaskTitle(taskId, newTitle)
316 if (result && result.code === 0) {
317 // 更新本地 task 状态,触发页面标题和 header 标题更新
318 updateTaskTitle(newTitle)
319 toast.success(t('task.titleUpdated'))
320 }
321 else {
322 throw new Error('Save failed')
323 }
324 },
325 [taskId, t, updateTaskTitle],
326 )
327
328 // 加载中状态(仅非活跃任务显示骨架屏)
329 if (isLoading && !isActiveTask) {
330 return <ChatLoadingSkeleton />
331 }
332
333 return (
334 <div className="flex flex-col h-full">
335 {/* 顶部导航 */}
336 <ChatHeader
337 title={task?.title}
338 defaultTitle={t('task.newChat')}
339 isGenerating={isGenerating}
340 progress={progress}
341 thinkingText={t('message.thinking')}
342 taskId={taskId}
343 rating={task?.rating ?? null}
344 isFavorited={isFavorited}
345 onFavoriteToggle={handleFavoriteToggle}
346 onEditTitle={() => setEditTitleOpen(true)}
347 onBack={handleBack}
348 />
349
350 {/* 消息列表 */}
351 <ChatMessageList
352 messages={displayMessages}
353 workflowSteps={isInterrupted ? [] : workflowSteps}
354 isGenerating={isGenerating}
355 containerRef={containerRef}
356 bottomRef={bottomRef}
357 showScrollButton={showScrollButton}
358 onScroll={handleScroll}
359 onScrollToBottom={() => scrollToBottom(true)}
360 scrollToBottomText={t('detail.scrollToBottom')}
361 taskId={taskId}
362 rating={localRating !== undefined ? localRating : (task?.rating ?? null)}
363 onRatingSaved={() => setLocalRating(1)}
364 />
365
366 {/* 底部输入区域 - 限宽居中 */}
367 <div className="p-4 shrink-0">
368 <div className="max-w-6xl mx-auto">
369 <ChatInput
370 value={inputValue}
371 onChange={setInputValue}
372 onSend={handleSend}
373 onStop={handleStop}
374 medias={medias}
375 onMediasChange={handleMediasChange}
376 onMediaRemove={handleMediaRemove}
377 onMediaUpdate={handleMediaUpdate}
378 isGenerating={isGenerating}
379 isUploading={isUploading}
380 placeholder={t('detail.continuePlaceholder')}
381 mode="compact"
382 />
383 </div>
384 </div>
385
386 {/* 发布详情弹框 - 显示插件发布进度 */}
387 <PublishDetailModal
388 visible={publishDetailVisible}
389 onClose={() => {
390 setPublishDetailVisible(false)
391 setCurrentPublishTaskId(undefined)
392 setUserClosedModal(true)
393 }}
394 taskId={currentPublishTaskId}
395 />
396
397 {/* 编辑标题弹窗 */}
398 <EditTitleModal
399 open={editTitleOpen}
400 onOpenChange={setEditTitleOpen}
401 currentTitle={task?.title || ''}
402 onSave={handleSaveTitle}
403 />
404 </div>
405 )
406 }
407
407 lines Plain Text