Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { AIChatMessage, User } from '../../types'; | |
| import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File, Globe, Square, Camera } from 'lucide-react'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import { compressImage } from '../../utils/mediaHelpers'; | |
| import { parseDocument } from '../../utils/documentParser'; | |
| import { Toast, ToastState } from '../Toast'; | |
| interface WorkAssistantPanelProps { | |
| currentUser: User | null; | |
| } | |
| // Optimized Roles for Formal School Context | |
| const ROLES = [ | |
| { | |
| id: 'editor', | |
| name: '公众号图文精修', | |
| icon: '📝', | |
| description: '自动优化文稿并智能排版图片', | |
| prompt: `你是一位拥有10年经验的学校官方公众号主编。你的任务是根据用户提供的草稿(或活动描述)以及上传的图片,撰写一篇高质量的官方推文。 | |
| ### 核心原则 | |
| 1. **文风要求**:端庄、大气、严谨,富有教育情怀。多用四字成语(如“精准赋能”、“蓄势扬帆”)。 | |
| 2. **严禁使用 Emoji**:保持学校官方文稿的严肃性,绝对不要出现任何表情包符号。 | |
| 3. **结构规范**:标题要对仗工整,正文要有层次感(如“校园寻迹”、“专题引领”等小标题)。 | |
| 4. **内容升华**:将具体活动上升到“立德树人”、“核心素养”、“高质量发展”等高度。 | |
| ### 图片排版规则 (至关重要) | |
| - **如果有图片**:用户上传了 {{IMAGE_COUNT}} 张图片。请务必将这 {{IMAGE_COUNT}} 张图片根据段落内容逻辑,以 **(图Start-图End)** 或 **(图N)** 的格式插入到文章最合适的位置。不要遗漏任何图片。 | |
| - **如果没有图片**:如果用户没有上传任何图片,**绝对不要**在文中插入任何 "(图x)" 占位符。直接输出纯文本文章即可。 | |
| ### 输出风格示例 | |
| 标题:精准赋能强基石 蓄势扬帆谋新篇 | |
| (正文段落,引用诗句或名言开篇...) | |
| ...校园每一处景观都被赋予了“微笑育人”的深刻内涵。(图1-图3) | |
| ...(小标题)... | |
| ...展现了跨学科融合的教学创新。(图4)` | |
| }, | |
| { | |
| id: 'host', | |
| name: '活动主持/致辞', | |
| icon: '🎤', | |
| description: '生成正式的活动主持词', | |
| prompt: `你是一位经验丰富的学校活动策划和主持人。 | |
| 协助老师撰写活动流程、主持稿或领导/嘉宾致辞。 | |
| 1. **风格**:庄重、大气、热情但不失礼仪。**严禁使用 Emoji**。 | |
| 2. **内容**:逻辑清晰,环节紧凑,串词自然过渡。 | |
| 3. **格式**:标明【环节名称】、【具体话术】。` | |
| }, | |
| { | |
| id: 'writer', | |
| name: '公文/通知润色', | |
| icon: '✍️', | |
| description: '将草稿转化为正式公文', | |
| prompt: `你是一位资深的教育系统笔杆子。 | |
| 请帮助老师将口语化或草稿文字转化为正式、规范的公文或通知。 | |
| 1. **目标**:准确、简练、得体。**严禁使用 Emoji**。 | |
| 2. **修正**:纠正错别字和语病,规范标点符号。 | |
| 3. **格式**:符合公文通报的标准格式。` | |
| }, | |
| { | |
| id: 'promoter', | |
| name: '宣传/海报文案', | |
| icon: '📢', | |
| description: '生成精炼的宣传标语', | |
| prompt: `你是一位擅长教育传播的文案专家。 | |
| 请为学校活动撰写短小精悍的宣传语,适用于海报、展板或家长群通知。 | |
| 1. **长度**:200字以内。 | |
| 2. **重点**:突出亮点,语言富有感染力,但保持端庄,不要使用过于浮夸的网络用语或 Emoji。 | |
| 3. **排版**:分行排版,重点突出。` | |
| }, | |
| { | |
| id: 'lesson_planner', | |
| name: '智能教案生成', | |
| icon: '📚', | |
| description: '根据内容一键生成标准教案', | |
| prompt: `你是一位拥有20年教龄的特级教师和教研组长,熟悉新课标要求。 | |
| 请根据用户提供的【教学内容】或【课文文章】,设计一份科学、严谨、符合教育教学规律的标准教案。 | |
| ### 核心原则 | |
| 1. **严禁使用 Emoji**:保持教案的专业性和严谨性。 | |
| 2. **结构完整**:必须包含以下标准环节。 | |
| 3. **以生为本**:体现学生的主体地位和核心素养的培养。 | |
| ### 输出格式 | |
| **一、教学目标** | |
| 1. 知识与技能:... | |
| 2. 过程与方法:... | |
| 3. 情感态度与价值观:... | |
| **二、教学重难点** | |
| 1. 重点:... | |
| 2. 难点:... | |
| **三、教学方法** | |
| (如:讲授法、讨论法、情境教学法等) | |
| **四、教学过程** | |
| 1. **环节一:导入新课** (设计意图:...) | |
| - ... | |
| 2. **环节二:新课讲授** (设计意图:...) | |
| - ... | |
| 3. **环节三:巩固练习** (设计意图:...) | |
| - ... | |
| 4. **环节四:课堂小结** | |
| - ... | |
| 5. **环节五:作业布置** (分层作业) | |
| - ... | |
| **五、板书设计** | |
| (简洁明了的板书结构)` | |
| }, | |
| { | |
| id: 'speech_maker', | |
| name: '演讲/说书稿', | |
| icon: '🎙️', | |
| description: '将资料转化为生动的讲稿', | |
| prompt: `你是一位金牌演讲撰稿人和故事讲述专家。 | |
| 请根据用户提供的文本资料,将其改写为一份极具感染力、适合口头表达的【演讲稿】或【说书稿】。 | |
| ### 核心原则 | |
| 1. **严禁使用 Emoji**:保持文稿的文学性和正式感。 | |
| 2. **口语化表达**:将书面语转化为听觉语言,多用短句,避免生僻词和冗长句式。 | |
| 3. **情感共鸣**:注重情感的递进和渲染,使听众产生共鸣。 | |
| ### 输出结构要求 | |
| 1. **【开场白】**:用一个引人入胜的提问、故事或金句开场,迅速抓住听众注意力。 | |
| 2. **【正文】**: | |
| - 逻辑清晰,层层递进。 | |
| - 多用具体的细节描写和生动的例子,画面感强。 | |
| - 适当加入“(停顿)”、“(重音)”、“(环视观众)”等演讲提示词,辅助老师表达。 | |
| 3. **【结语】**:升华主题,发出号召或留下回味,给听众留下深刻印象。 | |
| ### 风格要求 | |
| - 语言生动形象,抑扬顿挫。 | |
| - 根据内容定调:如果是说书,要有评书的韵味;如果是演讲,要有气势和感染力。` | |
| } | |
| ]; | |
| export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentUser }) => { | |
| const [selectedRole, setSelectedRole] = useState(ROLES[0]); | |
| const [enableThinking, setEnableThinking] = useState(false); | |
| const [enableSearch, setEnableSearch] = useState(false); | |
| const [isMobile, setIsMobile] = useState(false); | |
| const [messages, setMessages] = useState<AIChatMessage[]>([]); | |
| const [textInput, setTextInput] = useState(''); | |
| const [selectedImages, setSelectedImages] = useState<File[]>([]); | |
| const [docFile, setDocFile] = useState<File | null>(null); | |
| const [docContent, setDocContent] = useState<string>(''); | |
| const [docLoading, setDocLoading] = useState(false); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' }); | |
| const [isThinkingExpanded, setIsThinkingExpanded] = useState<Record<string, boolean>>({}); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const scrollContainerRef = useRef<HTMLDivElement>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const cameraInputRef = useRef<HTMLInputElement>(null); | |
| const docInputRef = useRef<HTMLInputElement>(null); | |
| const abortControllerRef = useRef<AbortController | null>(null); | |
| // Smart Scroll & Check Mobile | |
| useEffect(() => { | |
| const checkMobile = () => { | |
| const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; | |
| const mobile = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent.toLowerCase()); | |
| setIsMobile(mobile); | |
| }; | |
| checkMobile(); | |
| if (!scrollContainerRef.current || !messagesEndRef.current) return; | |
| const container = scrollContainerRef.current; | |
| const { scrollTop, scrollHeight, clientHeight } = container; | |
| const isNearBottom = scrollHeight - scrollTop - clientHeight < 150; | |
| const lastMsg = messages[messages.length - 1]; | |
| const isUserMsg = lastMsg?.role === 'user'; | |
| if (isNearBottom || (isUserMsg && !isProcessing)) { | |
| messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); | |
| } | |
| }, [messages, isProcessing, isThinkingExpanded]); | |
| const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files) { | |
| setSelectedImages(prev => [...prev, ...Array.from(e.target.files!)]); | |
| } | |
| }; | |
| const handleDocSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| const file = e.target.files[0]; | |
| setDocFile(file); | |
| setDocLoading(true); | |
| try { | |
| const text = await parseDocument(file); | |
| setDocContent(text); | |
| setToast({ show: true, message: '文档解析成功,将作为参考内容发送', type: 'success' }); | |
| } catch (err: any) { | |
| setToast({ show: true, message: '文档解析失败: ' + err.message, type: 'error' }); | |
| setDocFile(null); | |
| setDocContent(''); | |
| } finally { | |
| setDocLoading(false); | |
| } | |
| } | |
| }; | |
| const clearDoc = () => { | |
| setDocFile(null); | |
| setDocContent(''); | |
| if (docInputRef.current) docInputRef.current.value = ''; | |
| }; | |
| const handleCopy = (text: string) => { | |
| navigator.clipboard.writeText(text); | |
| setToast({ show: true, message: '内容已复制', type: 'success' }); | |
| }; | |
| const handleStopGeneration = () => { | |
| if (abortControllerRef.current) { | |
| abortControllerRef.current.abort(); | |
| abortControllerRef.current = null; | |
| setIsProcessing(false); | |
| setToast({ show: true, message: '已停止生成', type: 'error' }); | |
| } | |
| }; | |
| const handleSubmit = async () => { | |
| if ((!textInput.trim() && selectedImages.length === 0 && !docContent) || isProcessing) return; | |
| setIsProcessing(true); | |
| const currentText = textInput; | |
| const currentImages = [...selectedImages]; | |
| const currentDocText = docContent; | |
| const currentDocName = docFile?.name; | |
| setTextInput(''); | |
| setSelectedImages([]); | |
| clearDoc(); | |
| abortControllerRef.current = new AbortController(); | |
| const userMsgId = crypto.randomUUID(); | |
| const aiMsgId = crypto.randomUUID(); | |
| try { | |
| const base64Images = await Promise.all(currentImages.map(f => compressImage(f))); | |
| const newUserMsg: AIChatMessage = { | |
| id: userMsgId, | |
| role: 'user', | |
| text: currentText + (currentDocName ? `\n\n[附带文档]: ${currentDocName}` : ''), | |
| images: base64Images, | |
| timestamp: Date.now() | |
| }; | |
| const newAiMsg: AIChatMessage = { | |
| id: aiMsgId, | |
| role: 'model', | |
| text: '', | |
| thought: '', | |
| timestamp: Date.now(), | |
| images: base64Images, | |
| isSearching: enableSearch | |
| }; | |
| setMessages(prev => [...prev, newUserMsg, newAiMsg]); | |
| setTimeout(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); | |
| }, 100); | |
| if (enableThinking) { | |
| setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true })); | |
| } | |
| const dynamicSystemPrompt = selectedRole.prompt.replace(/{{IMAGE_COUNT}}/g, String(base64Images.length)); | |
| let finalPrompt = currentText; | |
| if (currentDocText) { | |
| finalPrompt += `\n\n【参考文档内容 (${currentDocName})】:\n${currentDocText}\n\n请根据上述文档内容和我的要求进行创作。`; | |
| } | |
| if (base64Images.length > 0) { | |
| finalPrompt += `\n\n[系统提示] 用户上传了 ${base64Images.length} 张图片。请务必在文章中合理位置插入 (图1-图X) 格式的占位符。`; | |
| } else { | |
| finalPrompt += `\n\n[系统提示] 用户未上传图片。请忽略所有图片排版指令,严禁在文中插入任何 (图x) 占位符,仅输出纯文字。`; | |
| } | |
| const response = await fetch('/api/ai/chat', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'x-user-username': currentUser?.username || '', | |
| 'x-user-role': currentUser?.role || '', | |
| 'x-school-id': currentUser?.schoolId || '' | |
| }, | |
| body: JSON.stringify({ | |
| text: finalPrompt, | |
| images: base64Images, | |
| history: [], | |
| enableThinking, | |
| enableSearch, | |
| overrideSystemPrompt: dynamicSystemPrompt, | |
| disableAudio: true | |
| }), | |
| signal: abortControllerRef.current.signal | |
| }); | |
| if (!response.ok) throw new Error(response.statusText); | |
| if (!response.body) throw new Error('No response body'); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let aiTextAccumulated = ''; | |
| let aiThoughtAccumulated = ''; | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parts = buffer.split('\n\n'); | |
| buffer = parts.pop() || ''; | |
| for (const line of parts) { | |
| if (line.startsWith('data: ')) { | |
| const jsonStr = line.replace('data: ', '').trim(); | |
| if (jsonStr === '[DONE]') break; | |
| try { | |
| const data = JSON.parse(jsonStr); | |
| if (data.type === 'thinking') { | |
| aiThoughtAccumulated += data.content; | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thought: aiThoughtAccumulated } : m)); | |
| } else if (data.type === 'text') { | |
| if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') { | |
| setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: false })); | |
| } | |
| aiTextAccumulated += data.content; | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: aiTextAccumulated, isSearching: false } : m)); | |
| } else if (data.type === 'search') { | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: true } : m)); | |
| } else if (data.type === 'error') { | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}`, isSearching: false } : m)); | |
| } | |
| } catch (e) {} | |
| } | |
| } | |
| } | |
| } catch (error: any) { | |
| if (error.name !== 'AbortError') { | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `抱歉,处理失败: ${error.message}`, isSearching: false } : m)); | |
| } | |
| } finally { | |
| setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: false } : m)); | |
| setIsProcessing(false); | |
| abortControllerRef.current = null; | |
| } | |
| }; | |
| const renderContent = (text: string, sourceImages: string[] | undefined) => { | |
| if (!sourceImages || sourceImages.length === 0) { | |
| return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>; | |
| } | |
| const splitRegex = /((?图\d+(?:-图\d+)?)?)/g; | |
| const parts = text.split(splitRegex); | |
| return ( | |
| <div> | |
| {parts.map((part, idx) => { | |
| const rangeMatch = part.match(/图(\d+)-图(\d+)/); | |
| const singleMatch = part.match(/图(\d+)/); | |
| if (rangeMatch) { | |
| const start = parseInt(rangeMatch[1]); | |
| const end = parseInt(rangeMatch[2]); | |
| const imagesToShow: string[] = []; | |
| for (let i = start; i <= end; i++) { | |
| if (sourceImages[i-1]) imagesToShow.push(sourceImages[i-1]); | |
| } | |
| if (imagesToShow.length > 0) { | |
| return ( | |
| <div key={idx} className="my-4 bg-gray-50 p-2 rounded-lg border border-gray-100"> | |
| <div className="grid grid-cols-2 md:grid-cols-3 gap-2"> | |
| {imagesToShow.map((img, i) => ( | |
| <div key={i} className="relative aspect-[4/3] group"> | |
| <img src={`data:image/jpeg;base64,${img}`} className="w-full h-full object-cover rounded-md cursor-pointer hover:opacity-90 transition-opacity" title={`图 ${start+i}`}/> | |
| <div className="absolute bottom-1 right-1 bg-black/60 text-white text-[10px] px-1.5 rounded">图 {start+i}</div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="text-center text-xs text-gray-400 mt-2 font-mono">{part}</div> | |
| </div> | |
| ); | |
| } | |
| } else if (singleMatch && !part.includes('-')) { | |
| const imgIndex = parseInt(singleMatch[1]) - 1; | |
| if (sourceImages[imgIndex]) { | |
| return ( | |
| <div key={idx} className="my-4 flex flex-col items-center"> | |
| <img | |
| src={`data:image/jpeg;base64,${sourceImages[imgIndex]}`} | |
| className="max-w-full md:max-w-sm rounded-lg shadow-sm border border-gray-200" | |
| alt={`Image ${imgIndex + 1}`} | |
| /> | |
| <div className="text-center text-xs text-gray-400 mt-1 font-mono">{part}</div> | |
| </div> | |
| ); | |
| } | |
| } | |
| return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm]} components={{p: ({node, ...props}) => <p className="mb-2 last:mb-0 inline" {...props}/>}}>{part}</ReactMarkdown>; | |
| })} | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="flex flex-col h-full bg-slate-50 relative"> | |
| {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>} | |
| <div className="bg-white px-6 py-3 border-b border-gray-200 flex flex-wrap gap-4 items-center justify-between shadow-sm shrink-0 z-20"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-bold text-gray-700">工作模式:</span> | |
| <div className="relative group"> | |
| <button className="flex items-center gap-2 px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-bold border border-indigo-100 hover:bg-indigo-100 transition-colors"> | |
| <span>{selectedRole.icon} {selectedRole.name}</span> | |
| <ChevronDown size={14}/> | |
| </button> | |
| <div className="absolute top-full left-0 mt-1 w-64 bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden hidden group-hover:block z-50"> | |
| {ROLES.map(role => ( | |
| <button | |
| key={role.id} | |
| onClick={() => setSelectedRole(role)} | |
| className={`w-full text-left px-4 py-3 hover:bg-indigo-50 flex items-start gap-3 border-b border-gray-50 last:border-0 ${selectedRole.id === role.id ? 'bg-indigo-50' : ''}`} | |
| > | |
| <div className="text-xl mt-0.5">{role.icon}</div> | |
| <div> | |
| <div className={`text-sm font-bold ${selectedRole.id === role.id ? 'text-indigo-700' : 'text-gray-800'}`}>{role.name}</div> | |
| <div className="text-xs text-gray-500 mt-0.5">{role.description}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-1" title="开启后AI会进行更深入的逻辑推演"> | |
| <label className="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" checked={enableThinking} onChange={e => setEnableThinking(e.target.checked)} className="sr-only peer"/> | |
| <div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div> | |
| </label> | |
| <span className="text-xs text-gray-600 font-bold">深度思考</span> | |
| </div> | |
| <div className="flex items-center gap-1" title="开启联网搜索"> | |
| <label className="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" checked={enableSearch} onChange={e => setEnableSearch(e.target.checked)} className="sr-only peer"/> | |
| <div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div> | |
| </label> | |
| <span className="text-xs text-gray-600 font-bold">联网搜索</span> | |
| </div> | |
| <div className="w-px h-6 bg-gray-200 mx-1"></div> | |
| <button onClick={() => setMessages([])} className="text-gray-400 hover:text-red-500 p-2 rounded-full hover:bg-red-50 transition-colors" title="清空对话"> | |
| <Trash2 size={18}/> | |
| </button> | |
| </div> | |
| </div> | |
| <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar"> | |
| {messages.length === 0 && ( | |
| <div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60"> | |
| <FileText size={48} className="mb-4 text-indigo-300"/> | |
| <p className="text-lg font-bold text-gray-600">我是你的{selectedRole.name}助理</p> | |
| <p className="text-sm mt-2 max-w-md text-center"> | |
| 请上传活动照片(支持批量)或文档(Word/PDF/Txt)<br/> | |
| 输入简单的活动描述,我会为你生成端庄大气的官方文稿。 | |
| </p> | |
| </div> | |
| )} | |
| {messages.map((msg, index) => { | |
| let sourceImages: string[] = msg.images || []; | |
| if (msg.role === 'model' && (!sourceImages || sourceImages.length === 0)) { | |
| const prevMsg = messages[index - 1]; | |
| if (prevMsg && prevMsg.role === 'user' && prevMsg.images) { | |
| sourceImages = prevMsg.images; | |
| } | |
| } | |
| return ( | |
| <div key={msg.id} className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''} max-w-5xl mx-auto w-full`}> | |
| <div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm ${msg.role === 'model' ? 'bg-white border border-indigo-100 text-indigo-600' : 'bg-blue-600 text-white'}`}> | |
| {msg.role === 'model' ? <Sparkles size={20}/> : <span className="font-bold text-xs">ME</span>} | |
| </div> | |
| <div className={`flex flex-col gap-2 max-w-[90%] ${msg.role === 'user' ? 'items-end' : 'items-start'}`}> | |
| {msg.role === 'model' && msg.thought && ( | |
| <div className="w-full bg-purple-50 rounded-xl border border-purple-100 overflow-hidden mb-2"> | |
| <button | |
| onClick={() => setIsThinkingExpanded(prev => ({ ...prev, [msg.id]: !prev[msg.id] }))} | |
| className="w-full px-4 py-2 flex items-center justify-between text-xs font-bold text-purple-700 bg-purple-100/50 hover:bg-purple-100 transition-colors" | |
| > | |
| <span className="flex items-center gap-2"><Brain size={14}/> 深度思考过程</span> | |
| {isThinkingExpanded[msg.id] ? <ChevronDown size={14}/> : <ChevronRight size={14}/>} | |
| </button> | |
| {isThinkingExpanded[msg.id] && ( | |
| <div className="p-4 text-xs text-purple-800 whitespace-pre-wrap leading-relaxed border-t border-purple-100 font-mono bg-white/50"> | |
| {msg.thought} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {msg.role === 'model' && msg.isSearching && ( | |
| <div className="flex items-center gap-2 bg-blue-50 text-blue-600 px-3 py-2 rounded-xl mb-2 text-xs border border-blue-100 animate-pulse w-fit"> | |
| <Globe size={14} className="animate-spin"/> | |
| <span>正在联网搜索相关信息...</span> | |
| </div> | |
| )} | |
| <div className={`p-5 rounded-2xl shadow-sm text-sm overflow-hidden relative group w-full ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'}`}> | |
| {msg.role === 'user' && sourceImages.length > 0 && ( | |
| <div className="grid grid-cols-4 gap-2 mb-3"> | |
| {sourceImages.map((img, i) => ( | |
| <div key={i} className="relative aspect-square"> | |
| <img src={`data:image/jpeg;base64,${img}`} className="w-full h-full object-cover rounded-lg border border-white/20" /> | |
| <div className="absolute bottom-0 right-0 bg-black/50 text-white text-[10px] px-1.5 rounded-tl-lg">图{i+1}</div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| <div className={`markdown-body ${msg.role === 'user' ? 'text-white' : ''}`}> | |
| {msg.role === 'model' ? renderContent(msg.text || '', sourceImages) : <p className="whitespace-pre-wrap">{msg.text}</p>} | |
| </div> | |
| {msg.role === 'model' && !isProcessing && ( | |
| <button | |
| onClick={() => handleCopy(msg.text || '')} | |
| className="absolute top-2 right-2 p-1.5 text-gray-400 hover:text-blue-600 bg-white/80 backdrop-blur rounded-lg opacity-0 group-hover:opacity-100 transition-all shadow-sm border border-gray-100" | |
| title="复制纯文本" | |
| > | |
| <Copy size={14}/> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {isProcessing && ( | |
| <div className="flex justify-center py-4"> | |
| <div className="bg-white px-4 py-2 rounded-full shadow-sm border border-indigo-100 flex items-center gap-2 text-indigo-600 text-sm animate-pulse"> | |
| <Bot size={16}/> | |
| <span>正在阅读资料并撰写文案...</span> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <div className="bg-white border-t border-gray-200 p-4 z-20"> | |
| <div className="max-w-4xl mx-auto flex flex-col gap-3"> | |
| {(selectedImages.length > 0 || docFile) && ( | |
| <div className="flex gap-2 overflow-x-auto pb-2 px-1 custom-scrollbar"> | |
| {selectedImages.map((file, idx) => ( | |
| <div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm"> | |
| <img src={URL.createObjectURL(file)} className="w-full h-full object-cover" /> | |
| <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer" onClick={() => setSelectedImages(prev => prev.filter((_, i) => i !== idx))}> | |
| <X size={16} className="text-white"/> | |
| </div> | |
| <div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div> | |
| </div> | |
| ))} | |
| {docFile && ( | |
| <div className="relative h-16 px-3 bg-indigo-50 border border-indigo-200 rounded-lg flex items-center justify-center shrink-0 min-w-[120px] max-w-[200px]"> | |
| <div className="flex flex-col items-start overflow-hidden"> | |
| <div className="flex items-center text-indigo-700 font-bold text-xs mb-1"> | |
| <File size={12} className="mr-1"/> | |
| {docLoading ? '解析中...' : '已解析'} | |
| </div> | |
| <span className="text-[10px] text-indigo-500 truncate w-full" title={docFile.name}>{docFile.name}</span> | |
| </div> | |
| <button onClick={clearDoc} className="absolute top-0.5 right-0.5 text-indigo-300 hover:text-red-500 p-0.5"><X size={12}/></button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div className="flex gap-2 items-end bg-gray-50 p-2 rounded-2xl border border-gray-200 focus-within:ring-2 focus-within:ring-indigo-100 focus-within:border-indigo-300 transition-all shadow-inner"> | |
| <div className="flex items-center"> | |
| <button onClick={() => fileInputRef.current?.click()} className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 rounded-full transition-colors shrink-0" title="相册/文件"> | |
| <ImageIcon size={22}/> | |
| </button> | |
| {isMobile && ( | |
| <button onClick={() => cameraInputRef.current?.click()} className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 rounded-full transition-colors shrink-0" title="拍照"> | |
| <Camera size={22}/> | |
| </button> | |
| )} | |
| </div> | |
| <input type="file" multiple accept="image/*" ref={fileInputRef} className="hidden" onChange={handleImageSelect} onClick={(e) => (e.currentTarget.value = '')} /> | |
| {isMobile && ( | |
| <input type="file" accept="image/*" capture="environment" ref={cameraInputRef} className="hidden" onChange={handleImageSelect} onClick={(e) => (e.currentTarget.value = '')} /> | |
| )} | |
| <button onClick={() => docInputRef.current?.click()} className={`p-2 rounded-full transition-colors shrink-0 ${docFile ? 'text-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-white hover:text-indigo-600'}`} title="上传文档 (Docx, PDF, Txt)"> | |
| <Paperclip size={22}/> | |
| </button> | |
| <input type="file" accept=".docx, .pdf, .txt" ref={docInputRef} className="hidden" onChange={handleDocSelect} onClick={(e) => (e.currentTarget.value = '')} /> | |
| <textarea | |
| className="flex-1 bg-transparent border-none outline-none text-sm resize-none max-h-32 py-3 px-2" | |
| placeholder={selectedRole.id === 'editor' ? "请输入活动描述,或直接上传活动方案文档..." : `给${selectedRole.name}下达指令...`} | |
| rows={1} | |
| value={textInput} | |
| onChange={e => setTextInput(e.target.value)} | |
| onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }} | |
| /> | |
| {isProcessing ? ( | |
| <button | |
| onClick={handleStopGeneration} | |
| className="p-3 bg-red-500 text-white rounded-xl shadow-sm hover:bg-red-600 transition-all shrink-0 animate-pulse" | |
| title="停止生成" | |
| > | |
| <Square size={20} fill="currentColor"/> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={handleSubmit} | |
| onMouseDown={(e) => e.preventDefault()} | |
| onTouchEnd={(e) => { e.preventDefault(); handleSubmit(); }} | |
| type="button" | |
| disabled={(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading} | |
| className={`p-3 rounded-xl transition-all shrink-0 shadow-sm ${(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-700 hover:scale-105'}`} | |
| > | |
| {isProcessing || docLoading ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>} | |
| </button> | |
| )} | |
| </div> | |
| <div className="flex justify-between text-xs text-gray-400 px-1"> | |
| <span>* 支持上传 Word/PDF/Txt 解析内容</span> | |
| <span>AI 生成内容仅供参考</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |