dvc890 commited on
Commit
c071bb8
·
verified ·
1 Parent(s): 3f2f630

Upload 67 files

Browse files
components/ai/WorkAssistantPanel.tsx CHANGED
@@ -1,44 +1,46 @@
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { AIChatMessage, User } from '../../types';
4
- import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { compressImage } from '../../utils/mediaHelpers';
 
8
  import { Toast, ToastState } from '../Toast';
9
 
10
  interface WorkAssistantPanelProps {
11
  currentUser: User | null;
12
  }
13
 
14
- // Optimized Roles based on user's requirements
15
  const ROLES = [
16
  {
17
  id: 'editor',
18
  name: '公众号图文精修',
19
  icon: '📝',
20
- description: '自动优化文稿并插入图片占位符',
21
- prompt: `你是一位拥有10年经验的学校公众号主编。你的任务是根据用户提供的草稿(或活动描述)以及上传的图片数量,重写并排版一篇高质量的公众号推文。
22
-
23
- ### 核心任务
24
- 1. **文案润色**:将文字修改为优雅、生动、富有教育情怀的风格(参考:初冬暖阳,万物含情)。
25
- 2. **标题生成**:提供3个吸引人的标题。
26
- 3. **图片排版(关键)**:
27
- - 用户上传了 {{IMAGE_COUNT}} 张图片。
28
- - 你必须将这 {{IMAGE_COUNT}} 张图片全部分配到文章的合适位置。
29
- - **格式要求**:请在段落结束后,使用 **(图Start-图End)** 或 **(图N)** 的格式插入占位符。
30
- - **智能分组**:不要一张张插入。请根据段落内容将相关图片分组。例如:
31
- - 描写校园环境时,插入 (图1-图5)
32
- - 描写大课间活动时,插入 (图6-图10)
33
- - 描写讲座时,插入 (图11-图12)
34
- - 请确保图片编号从1开始连续编号,不要遗漏,也不要超出用户上传的总数。
35
-
36
- ### 输出风格示例
37
- ...(正文内容)...
38
- ...细致观摩校园文化建设的匠心设计。(图1-图5)
39
-
40
- ...尽显校园的蓬勃生机。...让校长们在行走中感受优质校园的育人底蕴。(图6-图10)
41
- ...`
 
42
  },
43
  {
44
  id: 'host',
@@ -46,21 +48,32 @@ const ROLES = [
46
  icon: '🎤',
47
  description: '生成正式的活动主持词',
48
  prompt: `你是一位经验丰富的学校活动策划和主持人。
49
- 协助老师撰写活动流程、主持稿或致辞。
50
- 1. 风格:庄重、大气或热情(根据活动性质调整)。
51
- 2. 内容:逻辑清晰,环节紧凑,串词自然过渡。
52
- 3. 格式:标明【环节名称】、【具体话术】。`
 
 
 
 
 
 
 
 
 
 
 
53
  },
54
  {
55
  id: 'promoter',
56
- name: '朋友圈/短文案',
57
  icon: '📢',
58
- description: '生成短小精悍的宣传语',
59
- prompt: `你是一位擅长社交媒体传播的文案。
60
- 请为学校活动撰写短小精悍的宣传语,适用于朋友圈、班级群或海报。
61
- 1. 长度:200字以内。
62
- 2. 重点:突出亮点,号召行动,适当使用Emoji增加亲和力。
63
- 3. 格式:分行排版,便于手机阅读。`
64
  }
65
  ];
66
 
@@ -72,6 +85,12 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
72
  const [messages, setMessages] = useState<AIChatMessage[]>([]);
73
  const [textInput, setTextInput] = useState('');
74
  const [selectedImages, setSelectedImages] = useState<File[]>([]);
 
 
 
 
 
 
75
  const [isProcessing, setIsProcessing] = useState(false);
76
 
77
  // UI State
@@ -80,6 +99,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
80
 
81
  const messagesEndRef = useRef<HTMLDivElement>(null);
82
  const fileInputRef = useRef<HTMLInputElement>(null);
 
83
 
84
  useEffect(() => {
85
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
@@ -91,24 +111,53 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
91
  }
92
  };
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  const handleCopy = (text: string) => {
95
  navigator.clipboard.writeText(text);
96
  setToast({ show: true, message: '内容已复制', type: 'success' });
97
  };
98
 
99
  const handleSubmit = async () => {
100
- if ((!textInput.trim() && selectedImages.length === 0) || isProcessing) return;
101
 
102
  setIsProcessing(true);
103
  const currentText = textInput;
104
  const currentImages = [...selectedImages];
 
 
105
 
106
  // Reset Inputs
107
  setTextInput('');
108
  setSelectedImages([]);
 
109
 
110
- // ID generation
111
- const newAiMsgId = Date.now().toString();
 
112
 
113
  try {
114
  // Process Images to Base64
@@ -116,16 +165,16 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
116
 
117
  // User Message
118
  const newUserMsg: AIChatMessage = {
119
- id: (Date.now() - 1).toString(),
120
  role: 'user',
121
- text: currentText,
122
  images: base64Images,
123
  timestamp: Date.now()
124
  };
125
 
126
  // AI Placeholder
127
  const newAiMsg: AIChatMessage = {
128
- id: newAiMsgId,
129
  role: 'model',
130
  text: '',
131
  thought: '',
@@ -137,16 +186,25 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
137
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
138
 
139
  if (enableThinking) {
140
- setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: true }));
141
  }
142
 
143
  // Inject Image Count into System Prompt
144
- const dynamicSystemPrompt = selectedRole.prompt.replace('{{IMAGE_COUNT}}', String(base64Images.length));
145
 
146
- // Custom prompt logic for image indexing
147
  let finalPrompt = currentText;
 
 
 
 
 
 
 
148
  if (base64Images.length > 0) {
149
  finalPrompt += `\n\n[系统提示] 用户上传了 ${base64Images.length} 张图片。请务必在文章中合理位置插入 (图1-图X) 格式的占位符。`;
 
 
150
  }
151
 
152
  const response = await fetch('/api/ai/chat', {
@@ -160,7 +218,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
160
  body: JSON.stringify({
161
  text: finalPrompt,
162
  images: base64Images,
163
- history: [], // Work assistant usually doesn't need long history context to keep focus sharp
164
  enableThinking,
165
  overrideSystemPrompt: dynamicSystemPrompt,
166
  disableAudio: true
@@ -192,17 +250,18 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
192
  try {
193
  const data = JSON.parse(jsonStr);
194
 
 
195
  if (data.type === 'thinking') {
196
  aiThoughtAccumulated += data.content;
197
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
198
  } else if (data.type === 'text') {
199
  if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') {
200
- setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: false }));
201
  }
202
  aiTextAccumulated += data.content;
203
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
204
  } else if (data.type === 'error') {
205
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}` } : m));
206
  }
207
  } catch (e) {}
208
  }
@@ -210,7 +269,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
210
  }
211
 
212
  } catch (error: any) {
213
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `抱歉,处理失败: ${error.message}` } : m));
214
  } finally {
215
  setIsProcessing(false);
216
  }
@@ -338,8 +397,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
338
  <FileText size={48} className="mb-4 text-indigo-300"/>
339
  <p className="text-lg font-bold text-gray-600">我是你的{selectedRole.name}助理</p>
340
  <p className="text-sm mt-2 max-w-md text-center">
341
- 请上传活动照片(支持批量)并输入简单的活动描述。<br/>
342
- 我会为你生成图文并茂的文章,并自动排版图片位置。
343
  </p>
344
  </div>
345
  )}
@@ -418,7 +477,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
418
  <div className="flex justify-center py-4">
419
  <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">
420
  <Bot size={16}/>
421
- <span>正在撰写文案并排版图片...</span>
422
  </div>
423
  </div>
424
  )}
@@ -428,9 +487,11 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
428
  {/* Input Area */}
429
  <div className="bg-white border-t border-gray-200 p-4 z-20">
430
  <div className="max-w-4xl mx-auto flex flex-col gap-3">
431
- {/* Image Preview */}
432
- {selectedImages.length > 0 && (
 
433
  <div className="flex gap-2 overflow-x-auto pb-2 px-1 custom-scrollbar">
 
434
  {selectedImages.map((file, idx) => (
435
  <div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm">
436
  <img src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
@@ -440,10 +501,22 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
440
  <div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div>
441
  </div>
442
  ))}
443
- <div className="w-16 h-16 shrink-0 rounded-lg border-2 border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-400 cursor-pointer hover:border-indigo-400 hover:text-indigo-500 transition-colors" onClick={() => fileInputRef.current?.click()}>
444
- <Plus size={20}/>
445
- <span className="text-[9px]">添加</span>
446
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
447
  </div>
448
  )}
449
 
@@ -453,9 +526,14 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
453
  </button>
454
  <input type="file" multiple accept="image/*" ref={fileInputRef} className="hidden" onChange={handleImageSelect} onClick={(e) => (e.currentTarget.value = '')} />
455
 
 
 
 
 
 
456
  <textarea
457
  className="flex-1 bg-transparent border-none outline-none text-sm resize-none max-h-32 py-3 px-2"
458
- placeholder={selectedRole.id === 'editor' ? "请粘贴草稿文字或输入活动描述..." : `给${selectedRole.name}下达指令...`}
459
  rows={1}
460
  value={textInput}
461
  onChange={e => setTextInput(e.target.value)}
@@ -464,14 +542,14 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
464
 
465
  <button
466
  onClick={handleSubmit}
467
- disabled={(!textInput.trim() && selectedImages.length === 0) || isProcessing}
468
- className={`p-3 rounded-xl transition-all shrink-0 shadow-sm ${(!textInput.trim() && selectedImages.length === 0) || isProcessing ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-700 hover:scale-105'}`}
469
  >
470
- {isProcessing ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
471
  </button>
472
  </div>
473
  <div className="flex justify-between text-xs text-gray-400 px-1">
474
- <span>* 支持 Shift+Enter 换行</span>
475
  <span>AI 生成内容仅供参考</span>
476
  </div>
477
  </div>
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { AIChatMessage, User } from '../../types';
4
+ import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { compressImage } from '../../utils/mediaHelpers';
8
+ import { parseDocument } from '../../utils/documentParser';
9
  import { Toast, ToastState } from '../Toast';
10
 
11
  interface WorkAssistantPanelProps {
12
  currentUser: User | null;
13
  }
14
 
15
+ // Optimized Roles for Formal School Context
16
  const ROLES = [
17
  {
18
  id: 'editor',
19
  name: '公众号图文精修',
20
  icon: '📝',
21
+ description: '自动优化文稿并智能排版图片',
22
+ prompt: `你是一位拥有10年经验的学校官方公众号主编。你的任务是根据用户提供的草稿(或活动描述)以及上传的图片,撰写一篇高质量的官方推文。
23
+
24
+ ### 核心原则
25
+ 1. **严禁使用 Emoji**:保持学校官方文稿的端庄、大气、严谨。
26
+ 2. **文风要求**:用词优美、教育情怀浓厚、逻辑清晰。多用书面语,避免口语化。
27
+ 3. **标题生成**:提供3个符合教育系统风格的标题供选择。
28
+
29
+ ### 图片排版规则 (至关重要)
30
+ - **如果有图片**:用户上传了 {{IMAGE_COUNT}} 张图片。请务必将这 {{IMAGE_COUNT}} 张图片根据段落内容逻辑,以 **(图Start-图End)** 或 **(图N)** 的格式插入到文章最合适的位置。不要遗漏任何图片。
31
+ - **如果没有图片**:如果用户没有上传任何图片,**绝对不要**在文中插入任何 "(图x)" 占位符。直接输出纯文本文章即可。
32
+
33
+ ### 输出风格示例 (有图时)
34
+ ...(正文段落)...
35
+ ...细致观摩校园文化建设的匠心设计。(图1-图3)
36
+
37
+ ...(正文段落)...
38
+ ...尽显校园的蓬勃生机。(图4-图5)
39
+
40
+ ### 输出风格示例 (无图时)
41
+ ...(正文段落)...
42
+ ...细致观摩校园文化建设的匠心设计。
43
+ ...(正文段落)...`
44
  },
45
  {
46
  id: 'host',
 
48
  icon: '🎤',
49
  description: '生成正式的活动主持词',
50
  prompt: `你是一位经验丰富的学校活动策划和主持人。
51
+ 协助老师撰写活动流程、主持稿或领导/嘉宾致辞。
52
+ 1. **风格**:庄重、大气、热情但不失礼仪。严禁使用 Emoji。
53
+ 2. **内容**:逻辑清晰,环节紧凑,串词自然过渡。
54
+ 3. **格式**:标明【环节名称】、【具体话术】。`
55
+ },
56
+ {
57
+ id: 'writer',
58
+ name: '公文/通知润色',
59
+ icon: '✍️',
60
+ description: '将草稿转化为正式公文',
61
+ prompt: `你是一位资深的教育系统笔杆子。
62
+ 请帮助老师将口语化或草稿文字转化为正式、规范的公文或通知。
63
+ 1. **目标**:准确、简练、得体。严禁使用 Emoji。
64
+ 2. **修正**:纠正错别字和语病,规范标点符号。
65
+ 3. **格式**:符合公文通报的标准格式。`
66
  },
67
  {
68
  id: 'promoter',
69
+ name: '宣传/海报文案',
70
  icon: '📢',
71
+ description: '生成精炼的宣传标语',
72
+ prompt: `你是一位擅长教育传播的文案专家。
73
+ 请为学校活动撰写短小精悍的宣传语,适用于海报、展板或家长群通知。
74
+ 1. **长度**:200字以内。
75
+ 2. **重点**:突出亮点,语言富有感染力,但保持端庄,不要使用过于浮夸的网络用语或 Emoji
76
+ 3. **排版**:分行排版,重点突出。`
77
  }
78
  ];
79
 
 
85
  const [messages, setMessages] = useState<AIChatMessage[]>([]);
86
  const [textInput, setTextInput] = useState('');
87
  const [selectedImages, setSelectedImages] = useState<File[]>([]);
88
+
89
+ // Document State
90
+ const [docFile, setDocFile] = useState<File | null>(null);
91
+ const [docContent, setDocContent] = useState<string>('');
92
+ const [docLoading, setDocLoading] = useState(false);
93
+
94
  const [isProcessing, setIsProcessing] = useState(false);
95
 
96
  // UI State
 
99
 
100
  const messagesEndRef = useRef<HTMLDivElement>(null);
101
  const fileInputRef = useRef<HTMLInputElement>(null);
102
+ const docInputRef = useRef<HTMLInputElement>(null);
103
 
104
  useEffect(() => {
105
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
 
111
  }
112
  };
113
 
114
+ const handleDocSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
115
+ if (e.target.files && e.target.files.length > 0) {
116
+ const file = e.target.files[0];
117
+ setDocFile(file);
118
+ setDocLoading(true);
119
+ try {
120
+ const text = await parseDocument(file);
121
+ setDocContent(text);
122
+ setToast({ show: true, message: '文档解析成功,将作为参考内容发送', type: 'success' });
123
+ } catch (err: any) {
124
+ setToast({ show: true, message: '文档解析失败: ' + err.message, type: 'error' });
125
+ setDocFile(null);
126
+ setDocContent('');
127
+ } finally {
128
+ setDocLoading(false);
129
+ }
130
+ }
131
+ };
132
+
133
+ const clearDoc = () => {
134
+ setDocFile(null);
135
+ setDocContent('');
136
+ if (docInputRef.current) docInputRef.current.value = '';
137
+ };
138
+
139
  const handleCopy = (text: string) => {
140
  navigator.clipboard.writeText(text);
141
  setToast({ show: true, message: '内容已复制', type: 'success' });
142
  };
143
 
144
  const handleSubmit = async () => {
145
+ if ((!textInput.trim() && selectedImages.length === 0 && !docContent) || isProcessing) return;
146
 
147
  setIsProcessing(true);
148
  const currentText = textInput;
149
  const currentImages = [...selectedImages];
150
+ const currentDocText = docContent;
151
+ const currentDocName = docFile?.name;
152
 
153
  // Reset Inputs
154
  setTextInput('');
155
  setSelectedImages([]);
156
+ clearDoc();
157
 
158
+ // ID generation using UUID to prevent collisions
159
+ const userMsgId = crypto.randomUUID();
160
+ const aiMsgId = crypto.randomUUID();
161
 
162
  try {
163
  // Process Images to Base64
 
165
 
166
  // User Message
167
  const newUserMsg: AIChatMessage = {
168
+ id: userMsgId,
169
  role: 'user',
170
+ text: currentText + (currentDocName ? `\n\n[附带文档]: ${currentDocName}` : ''),
171
  images: base64Images,
172
  timestamp: Date.now()
173
  };
174
 
175
  // AI Placeholder
176
  const newAiMsg: AIChatMessage = {
177
+ id: aiMsgId,
178
  role: 'model',
179
  text: '',
180
  thought: '',
 
186
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
187
 
188
  if (enableThinking) {
189
+ setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true }));
190
  }
191
 
192
  // Inject Image Count into System Prompt
193
+ const dynamicSystemPrompt = selectedRole.prompt.replace(/{{IMAGE_COUNT}}/g, String(base64Images.length));
194
 
195
+ // Construct Final Prompt
196
  let finalPrompt = currentText;
197
+
198
+ // Add Doc Content
199
+ if (currentDocText) {
200
+ finalPrompt += `\n\n【参考文档内容 (${currentDocName})】:\n${currentDocText}\n\n请根据上述文档内容和我的要求进行创作。`;
201
+ }
202
+
203
+ // Add Image Logic
204
  if (base64Images.length > 0) {
205
  finalPrompt += `\n\n[系统提示] 用户上传了 ${base64Images.length} 张图片。请务必在文章中合理位置插入 (图1-图X) 格式的占位符。`;
206
+ } else {
207
+ finalPrompt += `\n\n[系统提示] 用户未上传图片。请忽略所有图片排版指令,严禁在文中插入任何 (图x) 占位符,仅输出纯文字。`;
208
  }
209
 
210
  const response = await fetch('/api/ai/chat', {
 
218
  body: JSON.stringify({
219
  text: finalPrompt,
220
  images: base64Images,
221
+ history: [], // Work assistant keeps context short
222
  enableThinking,
223
  overrideSystemPrompt: dynamicSystemPrompt,
224
  disableAudio: true
 
250
  try {
251
  const data = JSON.parse(jsonStr);
252
 
253
+ // Using functional update with ID check to ensure we only update the AI message
254
  if (data.type === 'thinking') {
255
  aiThoughtAccumulated += data.content;
256
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
257
  } else if (data.type === 'text') {
258
  if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') {
259
+ setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: false }));
260
  }
261
  aiTextAccumulated += data.content;
262
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: aiTextAccumulated } : m));
263
  } else if (data.type === 'error') {
264
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}` } : m));
265
  }
266
  } catch (e) {}
267
  }
 
269
  }
270
 
271
  } catch (error: any) {
272
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `抱歉,处理失败: ${error.message}` } : m));
273
  } finally {
274
  setIsProcessing(false);
275
  }
 
397
  <FileText size={48} className="mb-4 text-indigo-300"/>
398
  <p className="text-lg font-bold text-gray-600">我是你的{selectedRole.name}助理</p>
399
  <p className="text-sm mt-2 max-w-md text-center">
400
+ 请上传活动照片(支持批量)或文档(Word/PDF/Txt)<br/>
401
+ 输入简单的活动描述,我会为你生成端庄大气的官方文稿。
402
  </p>
403
  </div>
404
  )}
 
477
  <div className="flex justify-center py-4">
478
  <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">
479
  <Bot size={16}/>
480
+ <span>正在阅读资料并撰写文案...</span>
481
  </div>
482
  </div>
483
  )}
 
487
  {/* Input Area */}
488
  <div className="bg-white border-t border-gray-200 p-4 z-20">
489
  <div className="max-w-4xl mx-auto flex flex-col gap-3">
490
+
491
+ {/* Attachments Preview */}
492
+ {(selectedImages.length > 0 || docFile) && (
493
  <div className="flex gap-2 overflow-x-auto pb-2 px-1 custom-scrollbar">
494
+ {/* Images */}
495
  {selectedImages.map((file, idx) => (
496
  <div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm">
497
  <img src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
 
501
  <div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div>
502
  </div>
503
  ))}
504
+
505
+ {/* Document */}
506
+ {docFile && (
507
+ <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]">
508
+ <div className="flex flex-col items-start overflow-hidden">
509
+ <div className="flex items-center text-indigo-700 font-bold text-xs mb-1">
510
+ <File size={12} className="mr-1"/>
511
+ {docLoading ? '解析中...' : '已解析'}
512
+ </div>
513
+ <span className="text-[10px] text-indigo-500 truncate w-full" title={docFile.name}>{docFile.name}</span>
514
+ </div>
515
+ <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>
516
+ </div>
517
+ )}
518
+
519
+ {/* Add More Button (if needed logic) */}
520
  </div>
521
  )}
522
 
 
526
  </button>
527
  <input type="file" multiple accept="image/*" ref={fileInputRef} className="hidden" onChange={handleImageSelect} onClick={(e) => (e.currentTarget.value = '')} />
528
 
529
+ <button onClick={() => docInputRef.current?.click()} className={`p-3 rounded-xl 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)">
530
+ <Paperclip size={22}/>
531
+ </button>
532
+ <input type="file" accept=".docx, .pdf, .txt" ref={docInputRef} className="hidden" onChange={handleDocSelect} onClick={(e) => (e.currentTarget.value = '')} />
533
+
534
  <textarea
535
  className="flex-1 bg-transparent border-none outline-none text-sm resize-none max-h-32 py-3 px-2"
536
+ placeholder={selectedRole.id === 'editor' ? "请输入活动描述,或直接上传活动方案文档..." : `给${selectedRole.name}下达指令...`}
537
  rows={1}
538
  value={textInput}
539
  onChange={e => setTextInput(e.target.value)}
 
542
 
543
  <button
544
  onClick={handleSubmit}
545
+ disabled={(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading}
546
+ 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'}`}
547
  >
548
+ {isProcessing || docLoading ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
549
  </button>
550
  </div>
551
  <div className="flex justify-between text-xs text-gray-400 px-1">
552
+ <span>* 支持上传 Word/PDF/Txt 解析内容</span>
553
  <span>AI 生成内容仅供参考</span>
554
  </div>
555
  </div>
index.html CHANGED
@@ -1,3 +1,4 @@
 
1
  <!DOCTYPE html>
2
  <html lang="zh-CN">
3
  <head>
@@ -38,7 +39,9 @@
38
  "@google/genai": "https://esm.sh/@google/genai@^1.33.0",
39
  "react-markdown": "https://esm.sh/react-markdown@^10.1.0",
40
  "remark-gfm": "https://esm.sh/remark-gfm@^4.0.1",
41
- "vite-plugin-pwa": "https://esm.sh/vite-plugin-pwa@^1.2.0"
 
 
42
  }
43
  }
44
  </script>
@@ -54,4 +57,4 @@
54
  </div>
55
  <script type="module" src="/index.tsx"></script>
56
  </body>
57
- </html>
 
1
+
2
  <!DOCTYPE html>
3
  <html lang="zh-CN">
4
  <head>
 
39
  "@google/genai": "https://esm.sh/@google/genai@^1.33.0",
40
  "react-markdown": "https://esm.sh/react-markdown@^10.1.0",
41
  "remark-gfm": "https://esm.sh/remark-gfm@^4.0.1",
42
+ "vite-plugin-pwa": "https://esm.sh/vite-plugin-pwa@^1.2.0",
43
+ "mammoth": "https://esm.sh/mammoth@1.6.0",
44
+ "pdfjs-dist": "https://esm.sh/pdfjs-dist@3.11.174"
45
  }
46
  }
47
  </script>
 
57
  </div>
58
  <script type="module" src="/index.tsx"></script>
59
  </body>
60
+ </html>
utils/documentParser.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ // @ts-ignore
3
+ import mammoth from 'mammoth';
4
+ // @ts-ignore
5
+ import * as pdfjsLib from 'pdfjs-dist';
6
+
7
+ // Set worker for PDF.js. Using the same version from ESM CDN.
8
+ pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://esm.sh/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
9
+
10
+ export interface ParsedDocument {
11
+ fileName: string;
12
+ content: string;
13
+ fileType: string;
14
+ }
15
+
16
+ export const parseDocument = async (file: File): Promise<string> => {
17
+ const fileType = file.name.split('.').pop()?.toLowerCase();
18
+
19
+ if (fileType === 'txt') {
20
+ return await parseTxt(file);
21
+ } else if (fileType === 'docx') {
22
+ return await parseDocx(file);
23
+ } else if (fileType === 'pdf') {
24
+ return await parsePdf(file);
25
+ } else {
26
+ throw new Error('不支持的文件格式。仅支持 .txt, .docx, .pdf');
27
+ }
28
+ };
29
+
30
+ const parseTxt = (file: File): Promise<string> => {
31
+ return new Promise((resolve, reject) => {
32
+ const reader = new FileReader();
33
+ reader.onload = (e) => resolve(e.target?.result as string);
34
+ reader.onerror = (e) => reject(e);
35
+ reader.readAsText(file);
36
+ });
37
+ };
38
+
39
+ const parseDocx = (file: File): Promise<string> => {
40
+ return new Promise((resolve, reject) => {
41
+ const reader = new FileReader();
42
+ reader.onload = (e) => {
43
+ const arrayBuffer = e.target?.result as ArrayBuffer;
44
+ mammoth.extractRawText({ arrayBuffer: arrayBuffer })
45
+ .then((result: any) => resolve(result.value))
46
+ .catch((err: any) => reject(err));
47
+ };
48
+ reader.onerror = (e) => reject(e);
49
+ reader.readAsArrayBuffer(file);
50
+ });
51
+ };
52
+
53
+ const parsePdf = async (file: File): Promise<string> => {
54
+ return new Promise((resolve, reject) => {
55
+ const reader = new FileReader();
56
+ reader.onload = async (e) => {
57
+ try {
58
+ const typedarray = new Uint8Array(e.target?.result as ArrayBuffer);
59
+ const pdf = await pdfjsLib.getDocument(typedarray).promise;
60
+ let fullText = '';
61
+
62
+ for (let i = 1; i <= pdf.numPages; i++) {
63
+ const page = await pdf.getPage(i);
64
+ const textContent = await page.getTextContent();
65
+ const pageText = textContent.items.map((item: any) => item.str).join(' ');
66
+ fullText += pageText + '\n';
67
+ }
68
+ resolve(fullText);
69
+ } catch (err) {
70
+ reject(err);
71
+ }
72
+ };
73
+ reader.onerror = (e) => reject(e);
74
+ reader.readAsArrayBuffer(file);
75
+ });
76
+ };