stud-manager / components /ai /WorkAssistantPanel.tsx
dvc890's picture
Upload 67 files
2b32ad3 verified
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>
);
};