mixvideo-v2/apps/desktop/src/components/ChatInterface.tsx

702 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Send,
AlertCircle,
Sparkles,
Tag,
CheckCircle,
XCircle,
MapPin
} from 'lucide-react';
import { queryRagGrounding } from '../services/ragGroundingService';
import { RagGroundingQueryOptions, GroundingMetadata } from '../types/ragGrounding';
import { EnhancedChatMessageV2 } from './EnhancedChatMessageV2';
/**
* 聊天消息接口
*/
interface ChatMessage {
id: string;
type: 'user' | 'assistant';
content: string;
timestamp: Date;
status?: 'sending' | 'sent' | 'error';
metadata?: {
responseTime?: number;
modelUsed?: string;
sources?: Array<{
title: string;
uri?: string;
content?: any;
}>;
grounding_metadata?: GroundingMetadata;
};
}
/**
* 聊天界面组件属性
*/
interface ChatInterfaceProps {
/** 会话ID用于上下文保持 */
sessionId?: string;
/** 最大保留消息数量 */
maxMessages?: number;
/** 是否显示来源信息 */
showSources?: boolean;
/** 是否启用图片卡片 */
enableImageCards?: boolean;
/** 自定义样式类名 */
className?: string;
/** 占位符文本 */
placeholder?: string;
}
/**
* 聊天界面组件
* 遵循 Tauri 开发规范和 ag-ui 设计标准
* 支持上下文保留和最新3条记录限制
*/
export const ChatInterface: React.FC<ChatInterfaceProps> = ({
sessionId = 'default-session',
maxMessages = 3,
showSources = true,
enableImageCards = true,
className = '',
placeholder = '描述您的穿搭需求,比如场合、风格、身材特点等...'
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [allTags, setAllTags] = useState<string[]>([]);
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
const [showTagBubble, setShowTagBubble] = useState(false);
const [bubbleTags, _setBubbleTags] = useState<{categories: string[], environment: string[]}>({categories: [], environment: []});
const [bubblePosition, _setBubblePosition] = useState<{x: number, y: number}>({x: 0, y: 0});
const inputRef = useRef<HTMLTextAreaElement>(null);
const chatContainerRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
// 当消息更新时自动滚动
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
// 解析图片内容数据
const parseImageContent = useCallback((content: any) => {
if (!content || typeof content !== 'object') return null;
try {
return {
categories: content.categories || [],
description: content.description || '',
environment_tags: content.environment_tags || [],
environment_color: content.environment_color_pattern?.rgb_hex || '#f0f0f0',
models: content.models || [],
releaseDate: content.releaseDate || '',
runtime: content.runtime || ''
};
} catch (error) {
console.error('解析图片内容失败:', error);
return null;
}
}, []);
// 生成消息ID
const generateMessageId = useCallback(() => {
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
}, []);
// 限制消息数量,保留最新的消息
const limitMessages = useCallback((newMessages: ChatMessage[]) => {
if (newMessages.length <= maxMessages * 2) {
return newMessages;
}
// 保留最新的 maxMessages 对话(用户+助手消息对)
const pairs: ChatMessage[][] = [];
let currentPair: ChatMessage[] = [];
for (let i = newMessages.length - 1; i >= 0; i--) {
const message = newMessages[i];
if (message.type === 'assistant') {
currentPair.unshift(message);
if (currentPair.length === 2) {
pairs.unshift([...currentPair]);
currentPair = [];
}
} else if (message.type === 'user') {
currentPair.unshift(message);
}
}
// 如果有未完成的对话,也要保留
if (currentPair.length > 0) {
pairs.unshift(currentPair);
}
// 只保留最新的 maxMessages 对话
const limitedPairs = pairs.slice(0, maxMessages);
return limitedPairs.flat();
}, [maxMessages]);
// 发送消息
const handleSendMessage = useCallback(async () => {
if (!input.trim() || isLoading) return;
const userMessage: ChatMessage = {
id: generateMessageId(),
type: 'user',
content: input.trim(),
timestamp: new Date(),
status: 'sent'
};
const assistantMessage: ChatMessage = {
id: generateMessageId(),
type: 'assistant',
content: '',
timestamp: new Date(),
status: 'sending'
};
// 添加用户消息和占位助手消息
setMessages(prev => limitMessages([...prev, userMessage, assistantMessage]));
setInput('');
setIsLoading(true);
setError(null);
try {
const options: RagGroundingQueryOptions = {
sessionId,
includeMetadata: showSources,
timeout: 30000,
includeHistory: true,
maxHistoryMessages: 7
};
const result = await queryRagGrounding(userMessage.content, options);
if (result.success && result.data) {
// 更新助手消息
setMessages(prev => prev.map(msg =>
msg.id === assistantMessage.id
? {
...msg,
content: result.data!.answer,
status: 'sent',
metadata: {
responseTime: result.data!.response_time_ms,
modelUsed: result.data!.model_used,
sources: result.data!.grounding_metadata?.sources,
grounding_metadata: result.data!.grounding_metadata
}
}
: msg
));
} else {
throw new Error(result.error || '查询失败');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
setError(errorMessage);
// 更新助手消息为错误状态
setMessages(prev => prev.map(msg =>
msg.id === assistantMessage.id
? {
...msg,
content: `抱歉,查询时出现错误:${errorMessage}`,
status: 'error'
}
: msg
));
} finally {
setIsLoading(false);
}
}, [input, isLoading, sessionId, showSources, generateMessageId, limitMessages]);
// 处理键盘事件
const handleKeyPress = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}, [handleSendMessage]);
// 重试最后一条消息
const handleRetry = useCallback(() => {
if (messages.length >= 2) {
const lastUserMessage = messages[messages.length - 2];
if (lastUserMessage.type === 'user') {
setInput(lastUserMessage.content);
setMessages(prev => prev.slice(0, -2));
if (inputRef.current) {
inputRef.current.focus();
}
}
}
}, [messages]);
// 提取所有图片的标签
const extractAllTags = useCallback(() => {
const tagSet = new Set<string>();
messages.forEach(message => {
if (message.type === 'assistant' && message.metadata?.sources) {
message.metadata.sources.forEach(source => {
const imageData = parseImageContent(source.content);
if (imageData) {
// 添加分类标签
imageData.categories.forEach((tag: string) => tagSet.add(tag));
// 添加环境标签
imageData.environment_tags.forEach((tag: string) => tagSet.add(tag));
// 添加模特相关标签
imageData.models.forEach((model: any) => {
if (model.products) {
model.products.forEach((product: any) => {
if (product.category) {
product.category.forEach((cat: string) => tagSet.add(cat));
}
});
}
});
}
});
}
});
return Array.from(tagSet).sort();
}, [messages, parseImageContent]);
// 当消息更新时,更新标签列表
useEffect(() => {
const extractedTags = extractAllTags();
// 合并提取的标签和已选择的标签,确保所有选中的标签都在列表中
const combinedTags = [...new Set([...extractedTags, ...selectedTags])];
setAllTags(combinedTags);
}, [extractAllTags, selectedTags]);
// 切换标签选择状态
const toggleTag = useCallback((tag: string) => {
setSelectedTags(prev => {
const newTags = prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag];
return newTags;
});
}, []);
// 切换标签展开状态
const toggleTagsExpanded = useCallback(() => {
setIsTagsExpanded(prev => !prev);
}, []);
// 关闭标签气泡
const closeTagBubble = useCallback(() => {
setShowTagBubble(false);
}, []);
// 清空选中的标签
const clearSelectedTags = useCallback(() => {
setSelectedTags([]);
}, []);
// 根据选中的标签生成搜索文本
const generateSearchFromTags = useCallback(() => {
if (selectedTags.length === 0) return '';
const tagGroups = {
style: [] as string[],
color: [] as string[],
occasion: [] as string[],
other: [] as string[]
};
selectedTags.forEach(tag => {
if (tag.includes('色') || tag.includes('黑') || tag.includes('白') || tag.includes('红') || tag.includes('蓝') || tag.includes('粉') || tag.includes('绿') || tag.includes('黄') || tag.includes('紫') || tag.includes('橙')) {
tagGroups.color.push(tag);
} else if (tag.includes('职场') || tag.includes('约会') || tag.includes('度假') || tag.includes('休闲') || tag.includes('正式') || tag.includes('运动')) {
tagGroups.occasion.push(tag);
} else if (tag.includes('甜美') || tag.includes('优雅') || tag.includes('性感') || tag.includes('简约') || tag.includes('复古') || tag.includes('时尚')) {
tagGroups.style.push(tag);
} else {
tagGroups.other.push(tag);
}
});
let searchText = '我想要';
if (tagGroups.style.length > 0) {
searchText += `${tagGroups.style.join('、')}风格的`;
}
if (tagGroups.color.length > 0) {
searchText += `${tagGroups.color.join('、')}`;
}
if (tagGroups.occasion.length > 0) {
searchText += `适合${tagGroups.occasion.join('、')}`;
}
if (tagGroups.other.length > 0) {
searchText += `${tagGroups.other.join('、')}`;
}
searchText += '穿搭推荐';
return searchText;
}, [selectedTags]);
return (
<div className={`flex flex-col h-full enhanced-chat-interface rounded-xl shadow-lg border border-gray-200 overflow-y-auto ${className}`}>
{/* 聊天消息区域 */}
<div
ref={chatContainerRef}
className="flex-1 p-4 space-y-6 bg-transparent"
>
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gradient-to-br from-pink-100 to-purple-100 rounded-full flex items-center justify-center mb-6">
<Sparkles className="w-10 h-10 text-pink-500" />
</div>
<p className="text-gray-600 max-w-lg mb-8 text-lg leading-relaxed">
穿
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 max-w-2xl">
{[
'夏日清新穿搭推荐',
'职场女性如何搭配?',
'约会穿什么显气质?',
'小个子女生穿搭技巧',
'秋冬外套怎么选?',
'显瘦穿搭有什么秘诀?',
'色彩搭配的基本原则',
'配饰如何提升整体造型?'
].map((suggestion, index) => (
<button
key={index}
onClick={() => setInput(suggestion)}
className="px-3 py-2 text-sm text-pink-600 bg-pink-50 hover:bg-pink-100 rounded-lg transition-all duration-200 hover:shadow-md hover:scale-105"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<>
{messages.map((message) => (
<EnhancedChatMessageV2
key={message.id}
message={message}
showSources={showSources}
enableMaterialCards={enableImageCards}
enableReferences={true}
/>
))}
</>
)}
</div>
{/* 错误提示 */}
{error && (
<div className="mx-4 mb-2 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-red-700 text-sm">{error}</span>
<button
onClick={handleRetry}
className="ml-auto text-red-600 hover:text-red-800 text-sm font-medium"
>
</button>
</div>
)}
{/* 固定底部输入区域 */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 rounded-b-xl">
{/* 标签选择区域 */}
{allTags.length > 0 && (
<div className="border-b border-gray-100 bg-gradient-to-r from-pink-50 to-purple-50">
{/* 标签头部 - 始终显示 */}
<div className="px-4 py-3">
<div className="flex items-center justify-between">
{/* 左侧:标签标题和已选标签 */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<Tag className="w-4 h-4 text-pink-500 flex-shrink-0" />
<span className="text-sm font-medium text-gray-700 flex-shrink-0">
穿 ({selectedTags.length} )
</span>
{/* 已选标签预览 */}
{selectedTags.length > 0 && (
<div className="flex items-center gap-1 ml-2 overflow-hidden">
{selectedTags.slice(0, 3).map((tag, index) => (
<span
key={index}
className="inline-block px-2 py-1 bg-pink-500 text-white text-xs rounded-full flex-shrink-0"
>
{tag}
</span>
))}
{selectedTags.length > 3 && (
<span className="text-xs text-gray-500 flex-shrink-0">+{selectedTags.length - 3}</span>
)}
</div>
)}
</div>
{/* 右侧:操作按钮和展开按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* 操作按钮 */}
{selectedTags.length > 0 && (
<>
<button
onClick={() => setInput(generateSearchFromTags())}
className="text-xs text-pink-600 hover:text-pink-800 bg-white hover:bg-pink-50 px-3 py-1 rounded-full transition-all duration-200 shadow-sm hover:shadow-md border border-pink-200"
>
</button>
<button
onClick={clearSelectedTags}
className="text-xs text-gray-500 hover:text-gray-700 bg-white hover:bg-gray-100 px-3 py-1 rounded-full transition-all duration-200 shadow-sm border border-gray-200"
>
</button>
</>
)}
{/* 展开/折叠按钮 */}
<button
onClick={toggleTagsExpanded}
className="flex items-center gap-1 hover:bg-white hover:bg-opacity-50 rounded-lg px-2 py-1 transition-all duration-200"
>
{!isTagsExpanded && (
<div className="bg-pink-500 text-white px-2 py-1 rounded-full text-xs font-medium shadow-md">
</div>
)}
<svg
className={`w-4 h-4 text-pink-500 transition-transform duration-200 ${isTagsExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
{/* 标签内容 - 可折叠 */}
{isTagsExpanded && (
<div className="px-4 pb-4">
{/* 标签列表 */}
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{allTags.map((tag, index) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={index}
onClick={() => toggleTag(tag)}
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium transition-all duration-200 ${
isSelected
? 'bg-pink-500 text-white shadow-md transform scale-105'
: 'bg-white text-gray-700 hover:bg-pink-100 hover:text-pink-700 hover:shadow-sm border border-gray-200'
}`}
>
<span>{tag}</span>
{isSelected && (
<CheckCircle className="w-3 h-3" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
)}
{/* 输入区域 */}
<div className="p-4">
<div className="flex gap-3 items-start">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={placeholder}
disabled={isLoading}
className="w-full px-4 py-3 border border-gray-300 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
rows={1}
style={{
minHeight: '48px',
maxHeight: '120px',
height: 'auto'
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
}}
/>
</div>
<button
onClick={handleSendMessage}
disabled={!input.trim() || isLoading}
className="px-4 bg-pink-500 text-white rounded-xl hover:bg-pink-600 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center flex-shrink-0"
style={{ height: '48px', minHeight: '48px' }}
>
{isLoading ? (
<div className="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<div className="mt-2 text-center text-xs text-gray-500">
<span> Enter Shift + Enter </span>
</div>
</div>
</div>
{/* 标签详情气泡卡片 */}
{showTagBubble && (
<>
{/* 背景遮罩 */}
<div
className="fixed inset-0 z-40"
onClick={closeTagBubble}
/>
{/* 气泡卡片 */}
<div
className="fixed z-50 bg-white rounded-xl shadow-2xl border border-gray-200 max-w-sm w-80 transform -translate-x-1/2 -translate-y-full"
style={{
left: bubblePosition.x,
top: bubblePosition.y,
}}
>
{/* 气泡箭头 */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
<div className="w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-white"></div>
<div className="absolute -top-1 left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-8 border-r-8 border-t-8 border-l-transparent border-r-transparent border-t-gray-200"></div>
</div>
{/* 卡片头部 */}
<div className="flex items-center justify-between p-3 border-b border-gray-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-xl">
<div className="flex items-center gap-2">
<Tag className="w-4 h-4 text-pink-500" />
<h3 className="text-sm font-semibold text-gray-800"></h3>
</div>
<button
onClick={closeTagBubble}
className="p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
>
<XCircle className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* 卡片内容 */}
<div className="p-3 max-h-64 overflow-y-auto">
{/* 分类标签 */}
{bubbleTags.categories.length > 0 && (
<div className="mb-3">
<h4 className="text-xs font-medium text-gray-700 mb-2 flex items-center gap-1">
<Tag className="w-3 h-3 text-blue-500" />
({bubbleTags.categories.length})
</h4>
<div className="flex flex-wrap gap-1">
{bubbleTags.categories.map((category, index) => {
const isSelected = selectedTags.includes(category);
return (
<button
key={index}
onClick={() => toggleTag(category)}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
isSelected
? 'bg-pink-500 text-white shadow-md border-pink-600'
: 'bg-blue-50 text-blue-700 hover:bg-pink-100 hover:text-pink-700 border-blue-200 hover:border-pink-300'
}`}
>
<Tag className="w-2 h-2" />
{category}
</button>
);
})}
</div>
</div>
)}
{/* 环境标签 */}
{bubbleTags.environment.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-700 mb-2 flex items-center gap-1">
<MapPin className="w-3 h-3 text-green-500" />
({bubbleTags.environment.length})
</h4>
<div className="flex flex-wrap gap-1">
{bubbleTags.environment.map((tag, index) => {
const isSelected = selectedTags.includes(tag);
return (
<button
key={index}
onClick={() => toggleTag(tag)}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full transition-all duration-200 border ${
isSelected
? 'bg-pink-500 text-white shadow-md border-pink-600'
: 'bg-green-50 text-green-700 hover:bg-pink-100 hover:text-pink-700 border-green-200 hover:border-pink-300'
}`}
>
<MapPin className="w-2 h-2" />
{tag}
</button>
);
})}
</div>
</div>
)}
{/* 空状态 */}
{bubbleTags.categories.length === 0 && bubbleTags.environment.length === 0 && (
<div className="text-center py-4 text-gray-500">
<Tag className="w-8 h-8 mx-auto mb-1 text-gray-300" />
<p className="text-xs"></p>
</div>
)}
</div>
{/* 卡片底部 */}
<div className="px-3 py-2 border-t border-gray-100 bg-gray-50 rounded-b-xl">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">
{selectedTags.length}
</span>
<button
onClick={closeTagBubble}
className="px-3 py-1 bg-pink-500 text-white rounded-md hover:bg-pink-600 transition-colors duration-200 text-xs"
>
</button>
</div>
</div>
</div>
</>
)}
</div>
);
};