| 1 | /** |
| 2 | * HomeChat - 首页Chat组件 |
| 3 | * 功能:大尺寸聊天输入框,使用全局 AgentStore 发起 SSE 任务,获取 taskId 后跳转到对话详情页 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import { useParams, useRouter, useSearchParams } from 'next/navigation' |
| 9 | import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react' |
| 10 | import { useShallow } from 'zustand/react/shallow' |
| 11 | import { useTransClient } from '@/app/i18n/client' |
| 12 | import { useChannelManagerStore } from '@/components/ChannelManager' |
| 13 | import { ChatInput } from '@/components/Chat/ChatInput' |
| 14 | import { PlatformIcon } from '@/components/common/PlatformIcon' |
| 15 | import { useMediaUpload } from '@/hooks/useMediaUpload' |
| 16 | import { usePlatformInfoList } from '@/hooks/usePlatformMetadata' |
| 17 | import { useAccountStore } from '@/store/account' |
| 18 | import { useAgentStore } from '@/store/agent' |
| 19 | import { useUserStore } from '@/store/user' |
| 20 | |
| 21 | import { navigateToLogin } from '@/utils/auth' |
| 22 | import { cn } from '@/utils/className' |
| 23 | import { toast } from '@/utils/ui/toast' |
| 24 | import './style.css' |
| 25 | |
| 26 | export interface IHomeChatProps { |
| 27 | /** 登录检查回调 */ |
| 28 | onLoginRequired?: () => void |
| 29 | /** 自定义类名 */ |
| 30 | className?: string |
| 31 | /** 外部设置的提示词 */ |
| 32 | externalPrompt?: string |
| 33 | /** 外部设置的素材图片列表 */ |
| 34 | externalMaterials?: string[] |
| 35 | /** 清除外部提示词的回调 */ |
| 36 | onClearExternalPrompt?: () => void |
| 37 | /** 从任务页面跳转带来的任务ID,优先显示在输入框 */ |
| 38 | agentTaskId?: string |
| 39 | } |
| 40 | |
| 41 | /** HomeChat 组件的 ref 接口 */ |
| 42 | export interface IHomeChatRef { |
| 43 | /** 处理文件拖拽上传 */ |
| 44 | handleFileDrop: (files: FileList) => void |
| 45 | } |
| 46 | |
| 47 | /** |
| 48 | * HomeChat - 首页Chat组件 |
| 49 | */ |
| 50 | export const HomeChat = forwardRef<IHomeChatRef, IHomeChatProps>( |
| 51 | ({ onLoginRequired, className, externalPrompt, externalMaterials, onClearExternalPrompt, agentTaskId }, ref) => { |
| 52 | const { t } = useTransClient('chat') |
| 53 | const { t: tHome } = useTransClient('home') |
| 54 | const router = useRouter() |
| 55 | const platformList = usePlatformInfoList('publish') |
| 56 | const { lng } = useParams() |
| 57 | const token = useUserStore(state => state.token) |
| 58 | |
| 59 | // 获取默认提示文本 |
| 60 | const defaultPrompt |
| 61 | = t('input.placeholder') || 'Help me create a cat dancing video and post it directly on YouTube' |
| 62 | |
| 63 | // 状态 - 初始为空,使用 placeholder 显示提示文本 |
| 64 | const [inputValue, setInputValue] = useState('') |
| 65 | |
| 66 | // 当外部提示词或 agentTaskId 变化时更新输入框 |
| 67 | useEffect(() => { |
| 68 | if (agentTaskId) { |
| 69 | // 优先从 localStorage 读取 agentExternalPrompt(任务页可能在跳转前写入) |
| 70 | let desc = '' |
| 71 | try { |
| 72 | const stored = localStorage.getItem('agentExternalPrompt') |
| 73 | if (stored) { |
| 74 | desc = stored |
| 75 | localStorage.removeItem('agentExternalPrompt') |
| 76 | } |
| 77 | } |
| 78 | catch (e) { |
| 79 | // ignore |
| 80 | } |
| 81 | |
| 82 | desc = externalPrompt || defaultPrompt |
| 83 | |
| 84 | setInputValue(`${desc} TaskId: ${agentTaskId}`) |
| 85 | onClearExternalPrompt?.() |
| 86 | return |
| 87 | } |
| 88 | |
| 89 | if (externalPrompt) { |
| 90 | setInputValue(externalPrompt) |
| 91 | } |
| 92 | |
| 93 | // 处理外部 materials - 覆盖现有素材,并添加完整域名 |
| 94 | if (externalMaterials && externalMaterials.length > 0) { |
| 95 | const origin = typeof window !== 'undefined' ? window.location.origin : '' |
| 96 | const newMedias = externalMaterials.map((url, idx) => ({ |
| 97 | id: `external-${Date.now()}-${idx}`, |
| 98 | url: url.startsWith('http') ? url : `${origin}${url}`, |
| 99 | type: 'image' as const, |
| 100 | })) |
| 101 | setMedias(newMedias) // 覆盖而非追加 |
| 102 | } |
| 103 | |
| 104 | if (externalPrompt || (externalMaterials && externalMaterials.length > 0)) { |
| 105 | onClearExternalPrompt?.() |
| 106 | } |
| 107 | }, [externalPrompt, externalMaterials, onClearExternalPrompt, agentTaskId]) |
| 108 | const [isSubmitting, setIsSubmitting] = useState(false) |
| 109 | |
| 110 | // 频道管理器 |
| 111 | const { openConnectList } = useChannelManagerStore( |
| 112 | useShallow(state => ({ |
| 113 | openConnectList: state.openConnectList, |
| 114 | })), |
| 115 | ) |
| 116 | |
| 117 | // 处理添加账号点击 - 未登录时跳转登录页 |
| 118 | const handleAddChannelClick = useCallback(() => { |
| 119 | if (!token) { |
| 120 | navigateToLogin() |
| 121 | return |
| 122 | } |
| 123 | openConnectList() |
| 124 | }, [token, openConnectList]) |
| 125 | |
| 126 | // 使用媒体上传 Hook |
| 127 | const { |
| 128 | medias, |
| 129 | setMedias, |
| 130 | isUploading, |
| 131 | handleMediasChange, |
| 132 | handleMediaRemove, |
| 133 | handleMediaUpdate, |
| 134 | clearMedias, |
| 135 | } = useMediaUpload({ |
| 136 | onError: () => toast.error(t('media.uploadFailed')), |
| 137 | }) |
| 138 | |
| 139 | // 暴露方法给父组件(用于全屏拖拽上传) |
| 140 | useImperativeHandle( |
| 141 | ref, |
| 142 | () => ({ |
| 143 | handleFileDrop: (files: FileList) => { |
| 144 | handleMediasChange(files) |
| 145 | }, |
| 146 | }), |
| 147 | [handleMediasChange], |
| 148 | ) |
| 149 | |
| 150 | const searchParams = useSearchParams() |
| 151 | |
| 152 | useEffect(() => { |
| 153 | try { |
| 154 | if (!searchParams) |
| 155 | return |
| 156 | |
| 157 | // 处理从品牌推广页跳转来的参数 |
| 158 | const promptParam = searchParams.get('prompt') |
| 159 | |
| 160 | if (promptParam) { |
| 161 | setInputValue(decodeURIComponent(promptParam)) |
| 162 | // 清理 URL 参数 |
| 163 | if (typeof window !== 'undefined') { |
| 164 | const url = new URL(window.location.href) |
| 165 | url.searchParams.delete('prompt') |
| 166 | window.history.replaceState({}, '', url.toString()) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // 处理 AI 生成分享的参数 |
| 171 | const aiGenerated = searchParams.get('aiGenerated') |
| 172 | if (aiGenerated === 'true') { |
| 173 | const mediasParam = searchParams.get('medias') |
| 174 | const descriptionParam = searchParams.get('description') |
| 175 | if (descriptionParam) { |
| 176 | setInputValue(decodeURIComponent(descriptionParam) || defaultPrompt) |
| 177 | } |
| 178 | if (mediasParam) { |
| 179 | try { |
| 180 | const medias = JSON.parse(decodeURIComponent(mediasParam)) |
| 181 | if (Array.isArray(medias) && medias.length > 0) { |
| 182 | setMedias(prev => [ |
| 183 | { |
| 184 | id: `shared-${Date.now()}`, |
| 185 | url: medias[0].url, |
| 186 | type: 'image', |
| 187 | file: undefined, |
| 188 | }, |
| 189 | ...prev, |
| 190 | ]) |
| 191 | } |
| 192 | } |
| 193 | catch (e) { |
| 194 | // ignore parse errors |
| 195 | } |
| 196 | } |
| 197 | // remove params to avoid re-processing (replaceState) |
| 198 | if (typeof window !== 'undefined') { |
| 199 | const url = new URL(window.location.href) |
| 200 | url.searchParams.delete('aiGenerated') |
| 201 | url.searchParams.delete('medias') |
| 202 | url.searchParams.delete('description') |
| 203 | window.history.replaceState({}, '', url.toString()) |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | catch (e) { |
| 208 | // ignore |
| 209 | } |
| 210 | }, [searchParams]) |
| 211 | |
| 212 | // 全局 Store |
| 213 | const { setPendingTask, setActionContext } = useAgentStore() |
| 214 | |
| 215 | /** |
| 216 | * 设置 Action 上下文(用于处理任务结果的 action) |
| 217 | */ |
| 218 | useEffect(() => { |
| 219 | setActionContext({ |
| 220 | router, |
| 221 | lng: lng as string, |
| 222 | t: tHome, |
| 223 | }) |
| 224 | }, [router, lng, tHome, setActionContext]) |
| 225 | |
| 226 | /** 实际执行发送的函数 */ |
| 227 | const doSend = useCallback(() => { |
| 228 | // 如果用户没有输入,使用占位符文案 |
| 229 | const actualPrompt = inputValue.trim() || defaultPrompt |
| 230 | |
| 231 | // 保存当前输入 |
| 232 | const currentPrompt = actualPrompt |
| 233 | const currentMedias = [...medias] |
| 234 | |
| 235 | // 设置 loading 状态,保留输入内容让用户知道正在处理 |
| 236 | setIsSubmitting(true) |
| 237 | |
| 238 | // 将任务存入 store,立即跳转 |
| 239 | setPendingTask({ |
| 240 | prompt: currentPrompt, |
| 241 | medias: currentMedias, |
| 242 | }) |
| 243 | |
| 244 | // 立即跳转到聊天页面(使用 "new" 作为临时 taskId) |
| 245 | router.push(`/chat/new`) |
| 246 | }, [inputValue, medias, router, lng, setPendingTask]) |
| 247 | |
| 248 | /** 处理发送消息 */ |
| 249 | const handleSend = useCallback(async () => { |
| 250 | // 检查登录状态 - 未登录时存储 pendingTask 后跳转登录页 |
| 251 | if (!token) { |
| 252 | // 如果用户没有输入,使用占位符文案 |
| 253 | const actualPrompt = inputValue.trim() || defaultPrompt |
| 254 | sessionStorage.setItem('pendingTask', JSON.stringify({ prompt: actualPrompt, medias })) |
| 255 | navigateToLogin(`/chat/new`) |
| 256 | return |
| 257 | } |
| 258 | |
| 259 | // 余额不足检查 - 阈值 50(美分)与 LowBalanceAlertProvider 中的 BALANCE_THRESHOLD 一致 |
| 260 | const creditsBalance = useUserStore.getState().creditsBalance |
| 261 | if (creditsBalance < 50) { |
| 262 | useAccountStore.getState().setLowBalanceAlertOpen(true) |
| 263 | return |
| 264 | } |
| 265 | |
| 266 | // 执行发送逻辑 |
| 267 | doSend() |
| 268 | }, [token, doSend, inputValue, defaultPrompt, medias, lng]) |
| 269 | |
| 270 | return ( |
| 271 | <div className={cn('w-full max-w-3xl mx-auto', className)}> |
| 272 | {/* 标题区域 */} |
| 273 | <div className="text-center mb-6 px-4"> |
| 274 | <h1 className="text-2xl sm:text-3xl md:text-4xl text-foreground font-semibold leading-relaxed"> |
| 275 | {tHome('agentGenerator.subtitle')} |
| 276 | </h1> |
| 277 | </div> |
| 278 | |
| 279 | {/* 聊天输入框 */} |
| 280 | <ChatInput |
| 281 | value={inputValue} |
| 282 | onChange={setInputValue} |
| 283 | onSend={handleSend} |
| 284 | medias={medias} |
| 285 | onMediasChange={handleMediasChange} |
| 286 | onMediaRemove={handleMediaRemove} |
| 287 | onMediaUpdate={handleMediaUpdate} |
| 288 | isGenerating={isSubmitting} |
| 289 | isUploading={isUploading} |
| 290 | placeholder={defaultPrompt} |
| 291 | mode="large" |
| 292 | allowEmptySubmit |
| 293 | /> |
| 294 | |
| 295 | {/* 平台工具链接提示 */} |
| 296 | <div |
| 297 | className="flex items-center gap-3 mb-2 cursor-pointer bg-muted rounded-b-xl pt-4 pb-3 px-4 -mt-3 relative" |
| 298 | onClick={handleAddChannelClick} |
| 299 | > |
| 300 | <span className="text-sm text-muted-foreground whitespace-nowrap"> |
| 301 | {t('home.connectTools')} |
| 302 | </span> |
| 303 | <div className="flex items-center gap-1.5 flex-wrap"> |
| 304 | {platformList.map(platformInfo => ( |
| 305 | <PlatformIcon |
| 306 | platform={platformInfo.type} |
| 307 | key={platformInfo.type} |
| 308 | width={24} |
| 309 | height={24} |
| 310 | className="w-6 h-6 rounded-full object-contain hover:scale-110 hover:opacity-80 transition-all" |
| 311 | title={platformInfo.name} |
| 312 | /> |
| 313 | ))} |
| 314 | </div> |
| 315 | </div> |
| 316 | |
| 317 | </div> |
| 318 | ) |
| 319 | }, |
| 320 | ) |
| 321 | |
| 322 | export default HomeChat |
| 323 |