Spaces:
Running
Running
Update components/ai/WorkAssistantPanel.tsx
Browse files- components/ai/WorkAssistantPanel.tsx +138 -74
components/ai/WorkAssistantPanel.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 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 } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { compressImage } from '../../utils/mediaHelpers';
|
|
@@ -11,47 +11,55 @@ interface WorkAssistantPanelProps {
|
|
| 11 |
currentUser: User | null;
|
| 12 |
}
|
| 13 |
|
| 14 |
-
//
|
| 15 |
const ROLES = [
|
| 16 |
{
|
| 17 |
id: 'editor',
|
| 18 |
-
name: '公众号
|
| 19 |
icon: '📝',
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
},
|
| 27 |
{
|
| 28 |
id: 'host',
|
| 29 |
-
name: '活动主持/
|
| 30 |
icon: '🎤',
|
|
|
|
| 31 |
prompt: `你是一位经验丰富的学校活动策划和主持人。
|
| 32 |
协助老师撰写活动流程、主持稿或致辞。
|
| 33 |
1. 风格:庄重、大气或热情(根据活动性质调整)。
|
| 34 |
-
2. 内容:逻辑清晰,环节紧凑。
|
| 35 |
-
3. 格式:标明【环节名称】、【
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
id: 'writer',
|
| 39 |
-
name: '文案润色专家',
|
| 40 |
-
icon: '✍️',
|
| 41 |
-
prompt: `你是一位资深的文字编辑。
|
| 42 |
-
请帮助老师优化、润色草稿或口语化的文字。
|
| 43 |
-
1. 目标:使文字更加通顺、优雅、符合书面语规范。
|
| 44 |
-
2. 修正:纠正错别字和语病。
|
| 45 |
-
3. 提升:优化修辞,增强表现力,但保持原意不变。`
|
| 46 |
},
|
| 47 |
{
|
| 48 |
id: 'promoter',
|
| 49 |
-
name: '
|
| 50 |
icon: '📢',
|
|
|
|
| 51 |
prompt: `你是一位擅长社交媒体传播的文案。
|
| 52 |
请为学校活动撰写短小精悍的宣传语,适用于朋友圈、班级群或海报。
|
| 53 |
1. 长度:200字以内。
|
| 54 |
-
2. 重点:突出亮点,号召行动。
|
| 55 |
3. 格式:分行排版,便于手机阅读。`
|
| 56 |
}
|
| 57 |
];
|
|
@@ -122,23 +130,23 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 122 |
text: '',
|
| 123 |
thought: '',
|
| 124 |
timestamp: Date.now(),
|
| 125 |
-
|
|
|
|
| 126 |
};
|
| 127 |
|
| 128 |
setMessages(prev => [...prev, newUserMsg, newAiMsg]);
|
| 129 |
|
| 130 |
-
// Default expand thinking
|
| 131 |
if (enableThinking) {
|
| 132 |
setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: true }));
|
| 133 |
}
|
| 134 |
|
| 135 |
-
//
|
| 136 |
-
const
|
| 137 |
|
| 138 |
// Custom prompt logic for image indexing
|
| 139 |
let finalPrompt = currentText;
|
| 140 |
if (base64Images.length > 0) {
|
| 141 |
-
finalPrompt += `\n\n
|
| 142 |
}
|
| 143 |
|
| 144 |
const response = await fetch('/api/ai/chat', {
|
|
@@ -152,10 +160,10 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 152 |
body: JSON.stringify({
|
| 153 |
text: finalPrompt,
|
| 154 |
images: base64Images,
|
| 155 |
-
history:
|
| 156 |
enableThinking,
|
| 157 |
-
overrideSystemPrompt:
|
| 158 |
-
disableAudio: true
|
| 159 |
})
|
| 160 |
});
|
| 161 |
|
|
@@ -188,7 +196,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 188 |
aiThoughtAccumulated += data.content;
|
| 189 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
|
| 190 |
} else if (data.type === 'text') {
|
| 191 |
-
// Once text starts, collapse thinking
|
| 192 |
if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') {
|
| 193 |
setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: false }));
|
| 194 |
}
|
|
@@ -209,31 +216,69 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 209 |
}
|
| 210 |
};
|
| 211 |
|
| 212 |
-
/
|
|
|
|
|
|
|
|
|
|
| 213 |
const renderContent = (text: string, sourceImages: string[] | undefined) => {
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
return (
|
| 218 |
<div>
|
| 219 |
{parts.map((part, idx) => {
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
if (sourceImages[imgIndex]) {
|
| 224 |
return (
|
| 225 |
-
<div key={idx} className="my-4">
|
| 226 |
<img
|
| 227 |
src={`data:image/jpeg;base64,${sourceImages[imgIndex]}`}
|
| 228 |
-
className="max-w-full md:max-w-
|
| 229 |
alt={`Image ${imgIndex + 1}`}
|
| 230 |
/>
|
| 231 |
-
<div className="text-center text-xs text-gray-400 mt-1">
|
| 232 |
</div>
|
| 233 |
);
|
| 234 |
}
|
| 235 |
}
|
| 236 |
-
|
|
|
|
|
|
|
| 237 |
})}
|
| 238 |
</div>
|
| 239 |
);
|
|
@@ -246,21 +291,24 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 246 |
{/* Toolbar */}
|
| 247 |
<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">
|
| 248 |
<div className="flex items-center gap-2">
|
| 249 |
-
<span className="text-sm font-bold text-gray-700">工作
|
| 250 |
<div className="relative group">
|
| 251 |
<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">
|
| 252 |
<span>{selectedRole.icon} {selectedRole.name}</span>
|
| 253 |
<ChevronDown size={14}/>
|
| 254 |
</button>
|
| 255 |
-
<div className="absolute top-full left-0 mt-1 w-
|
| 256 |
{ROLES.map(role => (
|
| 257 |
<button
|
| 258 |
key={role.id}
|
| 259 |
onClick={() => setSelectedRole(role)}
|
| 260 |
-
className={`w-full text-left px-4 py-3
|
| 261 |
>
|
| 262 |
-
<
|
| 263 |
-
<
|
|
|
|
|
|
|
|
|
|
| 264 |
</button>
|
| 265 |
))}
|
| 266 |
</div>
|
|
@@ -268,7 +316,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 268 |
</div>
|
| 269 |
|
| 270 |
<div className="flex items-center gap-4">
|
| 271 |
-
<div className="flex items-center gap-2" title="
|
| 272 |
<span className="text-sm text-gray-600 font-medium flex items-center gap-1">
|
| 273 |
<Brain size={16} className={enableThinking ? "text-purple-600" : "text-gray-400"}/> 深度思考
|
| 274 |
</span>
|
|
@@ -277,7 +325,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 277 |
<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>
|
| 278 |
</label>
|
| 279 |
</div>
|
| 280 |
-
<button onClick={() => setMessages([])} className="text-gray-400 hover:text-red-500 p-2 rounded-full hover:bg-red-50 transition-colors">
|
| 281 |
<Trash2 size={18}/>
|
| 282 |
</button>
|
| 283 |
</div>
|
|
@@ -287,32 +335,35 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 287 |
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
|
| 288 |
{messages.length === 0 && (
|
| 289 |
<div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60">
|
| 290 |
-
<
|
| 291 |
-
<p className="text-lg font-bold">我是你的{selectedRole.name}</p>
|
| 292 |
-
<p className="text-sm">
|
|
|
|
|
|
|
|
|
|
| 293 |
</div>
|
| 294 |
)}
|
| 295 |
|
| 296 |
{messages.map((msg, index) => {
|
| 297 |
-
//
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
| 301 |
const prevMsg = messages[index - 1];
|
| 302 |
if (prevMsg && prevMsg.role === 'user' && prevMsg.images) {
|
| 303 |
sourceImages = prevMsg.images;
|
| 304 |
}
|
| 305 |
-
} else if (msg.role === 'user' && msg.images) {
|
| 306 |
-
sourceImages = msg.images;
|
| 307 |
}
|
| 308 |
|
| 309 |
return (
|
| 310 |
-
<div key={msg.id} className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''} max-w-
|
| 311 |
<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'}`}>
|
| 312 |
{msg.role === 'model' ? <Sparkles size={20}/> : <span className="font-bold text-xs">ME</span>}
|
| 313 |
</div>
|
| 314 |
|
| 315 |
-
<div className={`flex flex-col gap-2 max-w-[
|
| 316 |
{msg.role === 'model' && msg.thought && (
|
| 317 |
<div className="w-full bg-purple-50 rounded-xl border border-purple-100 overflow-hidden mb-2">
|
| 318 |
<button
|
|
@@ -330,10 +381,10 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 330 |
</div>
|
| 331 |
)}
|
| 332 |
|
| 333 |
-
<div className={`p-
|
| 334 |
-
{/* User Image Preview Grid */}
|
| 335 |
{msg.role === 'user' && sourceImages.length > 0 && (
|
| 336 |
-
<div className="grid grid-cols-
|
| 337 |
{sourceImages.map((img, i) => (
|
| 338 |
<div key={i} className="relative aspect-square">
|
| 339 |
<img src={`data:image/jpeg;base64,${img}`} className="w-full h-full object-cover rounded-lg border border-white/20" />
|
|
@@ -353,7 +404,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 353 |
<button
|
| 354 |
onClick={() => handleCopy(msg.text || '')}
|
| 355 |
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"
|
| 356 |
-
title="复制
|
| 357 |
>
|
| 358 |
<Copy size={14}/>
|
| 359 |
</button>
|
|
@@ -363,6 +414,14 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 363 |
</div>
|
| 364 |
);
|
| 365 |
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
<div ref={messagesEndRef} />
|
| 367 |
</div>
|
| 368 |
|
|
@@ -371,28 +430,32 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 371 |
<div className="max-w-4xl mx-auto flex flex-col gap-3">
|
| 372 |
{/* Image Preview */}
|
| 373 |
{selectedImages.length > 0 && (
|
| 374 |
-
<div className="flex gap-2 overflow-x-auto pb-2 px-1">
|
| 375 |
{selectedImages.map((file, idx) => (
|
| 376 |
<div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
| 377 |
<img src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
|
| 378 |
-
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
| 379 |
-
<
|
| 380 |
</div>
|
| 381 |
<div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div>
|
| 382 |
</div>
|
| 383 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
</div>
|
| 385 |
)}
|
| 386 |
|
| 387 |
-
<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">
|
| 388 |
-
<button onClick={() => fileInputRef.current?.click()} className=
|
| 389 |
<ImageIcon size={22}/>
|
| 390 |
</button>
|
| 391 |
<input type="file" multiple accept="image/*" ref={fileInputRef} className="hidden" onChange={handleImageSelect} onClick={(e) => (e.currentTarget.value = '')} />
|
| 392 |
|
| 393 |
<textarea
|
| 394 |
className="flex-1 bg-transparent border-none outline-none text-sm resize-none max-h-32 py-3 px-2"
|
| 395 |
-
placeholder={`给${selectedRole.name}下达指令...
|
| 396 |
rows={1}
|
| 397 |
value={textInput}
|
| 398 |
onChange={e => setTextInput(e.target.value)}
|
|
@@ -407,8 +470,9 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 407 |
{isProcessing ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
|
| 408 |
</button>
|
| 409 |
</div>
|
| 410 |
-
<div className="
|
| 411 |
-
*
|
|
|
|
| 412 |
</div>
|
| 413 |
</div>
|
| 414 |
</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 } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { compressImage } from '../../utils/mediaHelpers';
|
|
|
|
| 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',
|
| 45 |
+
name: '活动主持/致辞',
|
| 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 |
];
|
|
|
|
| 130 |
text: '',
|
| 131 |
thought: '',
|
| 132 |
timestamp: Date.now(),
|
| 133 |
+
// Pass user images to AI message so it can render the placeholders using the source images
|
| 134 |
+
images: base64Images
|
| 135 |
};
|
| 136 |
|
| 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 |
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
|
| 167 |
})
|
| 168 |
});
|
| 169 |
|
|
|
|
| 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 |
}
|
|
|
|
| 216 |
}
|
| 217 |
};
|
| 218 |
|
| 219 |
+
/**
|
| 220 |
+
* Advanced Text Renderer
|
| 221 |
+
* Detects (图N) or (图N-图M) patterns and renders image grids
|
| 222 |
+
*/
|
| 223 |
const renderContent = (text: string, sourceImages: string[] | undefined) => {
|
| 224 |
+
if (!sourceImages || sourceImages.length === 0) {
|
| 225 |
+
return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// Regex to catch: (图1) or (图1-图3) or (图1) ...
|
| 229 |
+
const splitRegex = /((?图\d+(?:-图\d+)?)?)/g;
|
| 230 |
+
const parts = text.split(splitRegex);
|
| 231 |
+
|
| 232 |
return (
|
| 233 |
<div>
|
| 234 |
{parts.map((part, idx) => {
|
| 235 |
+
// Check if part matches image placeholder pattern
|
| 236 |
+
const rangeMatch = part.match(/图(\d+)-图(\d+)/); // Range: 图1-图5
|
| 237 |
+
const singleMatch = part.match(/图(\d+)/); // Single: 图1
|
| 238 |
+
|
| 239 |
+
if (rangeMatch) {
|
| 240 |
+
const start = parseInt(rangeMatch[1]);
|
| 241 |
+
const end = parseInt(rangeMatch[2]);
|
| 242 |
+
const imagesToShow: string[] = [];
|
| 243 |
+
|
| 244 |
+
for (let i = start; i <= end; i++) {
|
| 245 |
+
if (sourceImages[i-1]) imagesToShow.push(sourceImages[i-1]);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
if (imagesToShow.length > 0) {
|
| 249 |
+
return (
|
| 250 |
+
<div key={idx} className="my-4 bg-gray-50 p-2 rounded-lg border border-gray-100">
|
| 251 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
| 252 |
+
{imagesToShow.map((img, i) => (
|
| 253 |
+
<div key={i} className="relative aspect-[4/3] group">
|
| 254 |
+
<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}`}/>
|
| 255 |
+
<div className="absolute bottom-1 right-1 bg-black/60 text-white text-[10px] px-1.5 rounded">图 {start+i}</div>
|
| 256 |
+
</div>
|
| 257 |
+
))}
|
| 258 |
+
</div>
|
| 259 |
+
<div className="text-center text-xs text-gray-400 mt-2 font-mono">{part}</div>
|
| 260 |
+
</div>
|
| 261 |
+
);
|
| 262 |
+
}
|
| 263 |
+
} else if (singleMatch && !part.includes('-')) {
|
| 264 |
+
// Single Image
|
| 265 |
+
const imgIndex = parseInt(singleMatch[1]) - 1;
|
| 266 |
if (sourceImages[imgIndex]) {
|
| 267 |
return (
|
| 268 |
+
<div key={idx} className="my-4 flex flex-col items-center">
|
| 269 |
<img
|
| 270 |
src={`data:image/jpeg;base64,${sourceImages[imgIndex]}`}
|
| 271 |
+
className="max-w-full md:max-w-sm rounded-lg shadow-sm border border-gray-200"
|
| 272 |
alt={`Image ${imgIndex + 1}`}
|
| 273 |
/>
|
| 274 |
+
<div className="text-center text-xs text-gray-400 mt-1 font-mono">{part}</div>
|
| 275 |
</div>
|
| 276 |
);
|
| 277 |
}
|
| 278 |
}
|
| 279 |
+
|
| 280 |
+
// Standard text
|
| 281 |
+
return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm]} components={{p: ({node, ...props}) => <p className="mb-2 last:mb-0 inline" {...props}/>}}>{part}</ReactMarkdown>;
|
| 282 |
})}
|
| 283 |
</div>
|
| 284 |
);
|
|
|
|
| 291 |
{/* Toolbar */}
|
| 292 |
<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">
|
| 293 |
<div className="flex items-center gap-2">
|
| 294 |
+
<span className="text-sm font-bold text-gray-700">工作模式:</span>
|
| 295 |
<div className="relative group">
|
| 296 |
<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">
|
| 297 |
<span>{selectedRole.icon} {selectedRole.name}</span>
|
| 298 |
<ChevronDown size={14}/>
|
| 299 |
</button>
|
| 300 |
+
<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">
|
| 301 |
{ROLES.map(role => (
|
| 302 |
<button
|
| 303 |
key={role.id}
|
| 304 |
onClick={() => setSelectedRole(role)}
|
| 305 |
+
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' : ''}`}
|
| 306 |
>
|
| 307 |
+
<div className="text-xl mt-0.5">{role.icon}</div>
|
| 308 |
+
<div>
|
| 309 |
+
<div className={`text-sm font-bold ${selectedRole.id === role.id ? 'text-indigo-700' : 'text-gray-800'}`}>{role.name}</div>
|
| 310 |
+
<div className="text-xs text-gray-500 mt-0.5">{role.description}</div>
|
| 311 |
+
</div>
|
| 312 |
</button>
|
| 313 |
))}
|
| 314 |
</div>
|
|
|
|
| 316 |
</div>
|
| 317 |
|
| 318 |
<div className="flex items-center gap-4">
|
| 319 |
+
<div className="flex items-center gap-2" title="开启后AI会进行更深入的逻辑推演">
|
| 320 |
<span className="text-sm text-gray-600 font-medium flex items-center gap-1">
|
| 321 |
<Brain size={16} className={enableThinking ? "text-purple-600" : "text-gray-400"}/> 深度思考
|
| 322 |
</span>
|
|
|
|
| 325 |
<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>
|
| 326 |
</label>
|
| 327 |
</div>
|
| 328 |
+
<button onClick={() => setMessages([])} className="text-gray-400 hover:text-red-500 p-2 rounded-full hover:bg-red-50 transition-colors" title="清空对话">
|
| 329 |
<Trash2 size={18}/>
|
| 330 |
</button>
|
| 331 |
</div>
|
|
|
|
| 335 |
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
|
| 336 |
{messages.length === 0 && (
|
| 337 |
<div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60">
|
| 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 |
)}
|
| 346 |
|
| 347 |
{messages.map((msg, index) => {
|
| 348 |
+
// Logic to retrieve images from the AI message itself (passed during creation)
|
| 349 |
+
// OR from the preceding user message if it's a model response
|
| 350 |
+
let sourceImages: string[] = msg.images || [];
|
| 351 |
+
|
| 352 |
+
if (msg.role === 'model' && (!sourceImages || sourceImages.length === 0)) {
|
| 353 |
+
// Fallback: look at previous user message
|
| 354 |
const prevMsg = messages[index - 1];
|
| 355 |
if (prevMsg && prevMsg.role === 'user' && prevMsg.images) {
|
| 356 |
sourceImages = prevMsg.images;
|
| 357 |
}
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
|
| 360 |
return (
|
| 361 |
+
<div key={msg.id} className={`flex gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''} max-w-5xl mx-auto w-full`}>
|
| 362 |
<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'}`}>
|
| 363 |
{msg.role === 'model' ? <Sparkles size={20}/> : <span className="font-bold text-xs">ME</span>}
|
| 364 |
</div>
|
| 365 |
|
| 366 |
+
<div className={`flex flex-col gap-2 max-w-[90%] ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
| 367 |
{msg.role === 'model' && msg.thought && (
|
| 368 |
<div className="w-full bg-purple-50 rounded-xl border border-purple-100 overflow-hidden mb-2">
|
| 369 |
<button
|
|
|
|
| 381 |
</div>
|
| 382 |
)}
|
| 383 |
|
| 384 |
+
<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'}`}>
|
| 385 |
+
{/* User Image Preview Grid (Only for user messages) */}
|
| 386 |
{msg.role === 'user' && sourceImages.length > 0 && (
|
| 387 |
+
<div className="grid grid-cols-4 gap-2 mb-3">
|
| 388 |
{sourceImages.map((img, i) => (
|
| 389 |
<div key={i} className="relative aspect-square">
|
| 390 |
<img src={`data:image/jpeg;base64,${img}`} className="w-full h-full object-cover rounded-lg border border-white/20" />
|
|
|
|
| 404 |
<button
|
| 405 |
onClick={() => handleCopy(msg.text || '')}
|
| 406 |
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"
|
| 407 |
+
title="复制纯文本"
|
| 408 |
>
|
| 409 |
<Copy size={14}/>
|
| 410 |
</button>
|
|
|
|
| 414 |
</div>
|
| 415 |
);
|
| 416 |
})}
|
| 417 |
+
{isProcessing && (
|
| 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 |
+
)}
|
| 425 |
<div ref={messagesEndRef} />
|
| 426 |
</div>
|
| 427 |
|
|
|
|
| 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" />
|
| 437 |
+
<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))}>
|
| 438 |
+
<X size={16} className="text-white"/>
|
| 439 |
</div>
|
| 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 |
|
| 450 |
+
<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">
|
| 451 |
+
<button onClick={() => fileInputRef.current?.click()} className={`p-3 rounded-xl transition-colors shrink-0 ${selectedImages.length > 0 ? 'text-indigo-600 bg-indigo-50' : 'text-gray-500 hover:bg-white hover:text-indigo-600'}`} title="上传图片">
|
| 452 |
<ImageIcon size={22}/>
|
| 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)}
|
|
|
|
| 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>
|
| 478 |
</div>
|