feat(页面): 聊天页面集成视频展示和链接预览

- MessageBubble.tsx 消息气泡组件:
  - 新增 searchVideos 属性接收视频搜索结果
  - 新增 onLinkClick 属性支持链接点击回调
  - 在消息内容中渲染 SearchVideosGrid 组件
  - 将 onLinkClick 传递给 MarkdownRenderer
- chat/[id]/page.tsx 聊天页面:
  - 添加 LinkPreviewModal 链接预览弹窗状态管理
  - 新增 handleLinkClick 处理链接点击打开预览
  - MessageBubble 传递 searchVideos 和 onLinkClick
  - 渲染 LinkPreviewModal 组件
This commit is contained in:
gaoziman 2025-12-22 22:01:19 +08:00
parent e0b82b6257
commit be03aebb09
2 changed files with 32 additions and 1 deletions

View File

@ -10,6 +10,7 @@ import { MessageBubble } from '@/components/features/MessageBubble';
import { ChatHeaderInfo } from '@/components/features/ChatHeader'; import { ChatHeaderInfo } from '@/components/features/ChatHeader';
import { SaveToNoteModal } from '@/components/features/SaveToNoteModal'; import { SaveToNoteModal } from '@/components/features/SaveToNoteModal';
import { PromptOptimizer } from '@/components/features/PromptOptimizer'; import { PromptOptimizer } from '@/components/features/PromptOptimizer';
import { LinkPreviewModal } from '@/components/features/LinkPreviewModal';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useConversation, useConversations } from '@/hooks/useConversations'; import { useConversation, useConversations } from '@/hooks/useConversations';
import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat'; import { useStreamChat, type ChatMessage } from '@/hooks/useStreamChat';
@ -47,6 +48,10 @@ export default function ChatPage({ params }: PageProps) {
const [noteModalOpen, setNoteModalOpen] = useState(false); const [noteModalOpen, setNoteModalOpen] = useState(false);
const [noteContent, setNoteContent] = useState(''); const [noteContent, setNoteContent] = useState('');
// 链接预览状态
const [linkPreviewOpen, setLinkPreviewOpen] = useState(false);
const [linkPreviewUrl, setLinkPreviewUrl] = useState<string | null>(null);
// 获取数据 // 获取数据
const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId); const { conversation, loading: conversationLoading, error: conversationError } = useConversation(chatId);
const { createConversation, updateConversation, deleteConversation } = useConversations(); const { createConversation, updateConversation, deleteConversation } = useConversations();
@ -342,6 +347,12 @@ export default function ChatPage({ params }: PageProps) {
} }
}; };
// 处理链接点击 - 在预览弹窗中打开
const handleLinkClick = (url: string) => {
setLinkPreviewUrl(url);
setLinkPreviewOpen(true);
};
// 转换模型格式 // 转换模型格式
const modelOptions = models.map((m) => ({ const modelOptions = models.map((m) => ({
id: m.modelId, id: m.modelId,
@ -568,12 +579,14 @@ export default function ChatPage({ params }: PageProps) {
error={message.error} error={message.error}
images={message.images} images={message.images}
searchImages={message.searchImages} searchImages={message.searchImages}
searchVideos={message.searchVideos}
uploadedImages={message.uploadedImages} uploadedImages={message.uploadedImages}
uploadedDocuments={message.uploadedDocuments} uploadedDocuments={message.uploadedDocuments}
usedTools={message.usedTools} usedTools={message.usedTools}
pyodideStatus={message.pyodideStatus} pyodideStatus={message.pyodideStatus}
onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined} onRegenerate={message.role === 'assistant' && !isStreaming ? handleRegenerate : undefined}
onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined} onSaveToNote={message.role === 'assistant' && !isStreaming ? handleSaveToNote : undefined}
onLinkClick={handleLinkClick}
conversationId={chatId} conversationId={chatId}
/> />
)) ))
@ -624,6 +637,13 @@ export default function ChatPage({ params }: PageProps) {
conversationId={chatId} conversationId={chatId}
/> />
{/* 链接预览弹窗 */}
<LinkPreviewModal
url={linkPreviewUrl}
isOpen={linkPreviewOpen}
onClose={() => setLinkPreviewOpen(false)}
/>
{/* 提示词优化工具浮动按钮 */} {/* 提示词优化工具浮动按钮 */}
<PromptOptimizer onUsePrompt={setOptimizedPrompt} /> <PromptOptimizer onUsePrompt={setOptimizedPrompt} />
</div> </div>

View File

@ -8,6 +8,7 @@ import { Tooltip } from '@/components/ui/Tooltip';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer'; import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult'; import { CodeExecutionResult, PyodideLoading } from '@/components/features/CodeExecutionResult';
import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid'; import { SearchImagesGrid, type SearchImageItem } from '@/components/features/SearchImagesGrid';
import { SearchVideosGrid, type SearchVideoItem } from '@/components/features/SearchVideosGrid';
import { ImageLightbox } from '@/components/ui/ImageLightbox'; import { ImageLightbox } from '@/components/ui/ImageLightbox';
import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview'; import { DocumentPreview, type DocumentData } from '@/components/ui/DocumentPreview';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -33,6 +34,8 @@ interface MessageBubbleProps {
images?: string[]; images?: string[];
/** 搜索到的图片(来自图片搜索工具) */ /** 搜索到的图片(来自图片搜索工具) */
searchImages?: SearchImageItem[]; searchImages?: SearchImageItem[];
/** 搜索到的视频(来自视频搜索工具) */
searchVideos?: SearchVideoItem[];
/** 用户上传的图片Base64 或 URL */ /** 用户上传的图片Base64 或 URL */
uploadedImages?: string[]; uploadedImages?: string[];
/** 用户上传的文档 */ /** 用户上传的文档 */
@ -49,6 +52,8 @@ interface MessageBubbleProps {
onRegenerate?: (messageId: string) => void; onRegenerate?: (messageId: string) => void;
/** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */ /** 保存到笔记回调(仅对 AI 消息有效),传入消息内容 */
onSaveToNote?: (content: string) => void; onSaveToNote?: (content: string) => void;
/** 链接点击回调,用于在预览窗口中打开链接 */
onLinkClick?: (url: string) => void;
/** 对话 ID用于关联笔记来源 */ /** 对话 ID用于关联笔记来源 */
conversationId?: string; conversationId?: string;
} }
@ -70,7 +75,7 @@ function getDocumentIcon(type: string) {
return FileText; return FileText;
} }
export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, conversationId }: MessageBubbleProps) { export function MessageBubble({ message, user, thinkingContent, isStreaming, error, images, searchImages, searchVideos, uploadedImages, uploadedDocuments, usedTools, pyodideStatus, onRegenerate, onSaveToNote, onLinkClick, conversationId }: MessageBubbleProps) {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [thinkingExpanded, setThinkingExpanded] = useState(false); const [thinkingExpanded, setThinkingExpanded] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -284,11 +289,17 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
<SearchImagesGrid images={searchImages} className="mt-0 mb-4" /> <SearchImagesGrid images={searchImages} className="mt-0 mb-4" />
)} )}
{/* 搜索到的视频(视频搜索工具结果)- 显示在图片下方 */}
{searchVideos && searchVideos.length > 0 && (
<SearchVideosGrid videos={searchVideos} className="mt-0 mb-4" />
)}
<div className="text-[var(--color-text-primary)] leading-[1.75]"> <div className="text-[var(--color-text-primary)] leading-[1.75]">
{message.content ? ( {message.content ? (
<MarkdownRenderer <MarkdownRenderer
content={message.content} content={message.content}
onImageLinkClick={handleImageLinkClick} onImageLinkClick={handleImageLinkClick}
onLinkClick={onLinkClick}
/> />
) : isStreaming ? ( ) : isStreaming ? (
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]"> <div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">