返回 AiToEarn
index.tsx
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
323 lines Plain Text