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 = ({ 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([]); const [textInput, setTextInput] = useState(''); const [selectedImages, setSelectedImages] = useState([]); const [docFile, setDocFile] = useState(null); const [docContent, setDocContent] = useState(''); const [docLoading, setDocLoading] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [toast, setToast] = useState({ show: false, message: '', type: 'success' }); const [isThinkingExpanded, setIsThinkingExpanded] = useState>({}); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); const fileInputRef = useRef(null); const cameraInputRef = useRef(null); const docInputRef = useRef(null); const abortControllerRef = useRef(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) => { if (e.target.files) { setSelectedImages(prev => [...prev, ...Array.from(e.target.files!)]); } }; const handleDocSelect = async (e: React.ChangeEvent) => { 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 {text}; } const splitRegex = /((?图\d+(?:-图\d+)?)?)/g; const parts = text.split(splitRegex); return (
{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 (
{imagesToShow.map((img, i) => (
图 {start+i}
))}
{part}
); } } else if (singleMatch && !part.includes('-')) { const imgIndex = parseInt(singleMatch[1]) - 1; if (sourceImages[imgIndex]) { return (
{`Image
{part}
); } } return

}}>{part}; })}

); }; return (
{toast.show && setToast({...toast, show: false})}/>}
工作模式:
{ROLES.map(role => ( ))}
深度思考
联网搜索
{messages.length === 0 && (

我是你的{selectedRole.name}助理

请上传活动照片(支持批量)或文档(Word/PDF/Txt)
输入简单的活动描述,我会为你生成端庄大气的官方文稿。

)} {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 (
{msg.role === 'model' ? : ME}
{msg.role === 'model' && msg.thought && (
{isThinkingExpanded[msg.id] && (
{msg.thought}
)}
)} {msg.role === 'model' && msg.isSearching && (
正在联网搜索相关信息...
)}
{msg.role === 'user' && sourceImages.length > 0 && (
{sourceImages.map((img, i) => (
图{i+1}
))}
)}
{msg.role === 'model' ? renderContent(msg.text || '', sourceImages) :

{msg.text}

}
{msg.role === 'model' && !isProcessing && ( )}
); })} {isProcessing && (
正在阅读资料并撰写文案...
)}
{(selectedImages.length > 0 || docFile) && (
{selectedImages.map((file, idx) => (
setSelectedImages(prev => prev.filter((_, i) => i !== idx))}>
图{idx+1}
))} {docFile && (
{docLoading ? '解析中...' : '已解析'}
{docFile.name}
)}
)}
{isMobile && ( )}
(e.currentTarget.value = '')} /> {isMobile && ( (e.currentTarget.value = '')} /> )} (e.currentTarget.value = '')} />