| 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 |