Spaces:
Sleeping
Sleeping
Upload 67 files
Browse files- components/ai/WorkAssistantPanel.tsx +142 -64
- index.html +5 -2
- utils/documentParser.ts +76 -0
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
|
| 15 |
const ROLES = [
|
| 16 |
{
|
| 17 |
id: 'editor',
|
| 18 |
name: '公众号图文精修',
|
| 19 |
icon: '📝',
|
| 20 |
-
description: '
|
| 21 |
-
prompt: `你是一位拥有10
|
| 22 |
-
|
| 23 |
-
###
|
| 24 |
-
1.
|
| 25 |
-
2.
|
| 26 |
-
3.
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 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.
|
| 62 |
-
2.
|
| 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
|
|
|
|
| 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:
|
| 120 |
role: 'user',
|
| 121 |
-
text: currentText,
|
| 122 |
images: base64Images,
|
| 123 |
timestamp: Date.now()
|
| 124 |
};
|
| 125 |
|
| 126 |
// AI Placeholder
|
| 127 |
const newAiMsg: AIChatMessage = {
|
| 128 |
-
id:
|
| 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, [
|
| 141 |
}
|
| 142 |
|
| 143 |
// Inject Image Count into System Prompt
|
| 144 |
-
const dynamicSystemPrompt = selectedRole.prompt.replace(
|
| 145 |
|
| 146 |
-
//
|
| 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
|
| 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 ===
|
| 198 |
} else if (data.type === 'text') {
|
| 199 |
if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') {
|
| 200 |
-
setIsThinkingExpanded(prev => ({ ...prev, [
|
| 201 |
}
|
| 202 |
aiTextAccumulated += data.content;
|
| 203 |
-
setMessages(prev => prev.map(m => m.id ===
|
| 204 |
} else if (data.type === 'error') {
|
| 205 |
-
setMessages(prev => prev.map(m => m.id ===
|
| 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 ===
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 432 |
-
{
|
|
|
|
| 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 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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' ? "
|
| 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>*
|
| 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 |
+
};
|