import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api'; import type { Conversation, Message } from '@/types'; import './ChatPage.css'; // ===== Simple Markdown Renderer ===== const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { const html = useMemo(() => { let text = content; // Code blocks (```) text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => { return `
${escapeHtml(code.trim())}
`; }); // Inline code (`) text = text.replace(/`([^`]+)`/g, '$1'); // Bold (**text**) text = text.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic (*text*) text = text.replace(/\*(.+?)\*/g, '$1'); // Headers (## heading) text = text.replace(/^### (.+)$/gm, '

$1

'); text = text.replace(/^## (.+)$/gm, '

$1

'); text = text.replace(/^# (.+)$/gm, '

$1

'); // Unordered list items text = text.replace(/^- (.+)$/gm, '
  • $1
  • '); // Ordered list items text = text.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); // Horizontal rule text = text.replace(/^---$/gm, '
    '); // Paragraph spacing: double newlines text = text.replace(/\n\n/g, '

    '); // Single newlines to
    text = text.replace(/\n/g, '
    '); return text; }, [content]); return
    ; }; function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ===== Time Formatting ===== function formatTime(iso: string): string { const date = new Date(iso); const now = new Date(); const diff = now.getTime() - date.getTime(); const oneDay = 24 * 60 * 60 * 1000; if (diff < oneDay) { return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); } else if (diff < 7 * oneDay) { const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; return days[date.getDay()]; } else if (date.getFullYear() === now.getFullYear()) { return `${date.getMonth() + 1}/${date.getDate()}`; } else { return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; } } // ===== Helpers: normalize API responses ===== function unwrapList(res: any): any[] { const data = res?.data !== undefined ? res.data : res; if (Array.isArray(data)) return data; if (data?.items) return data.items; return []; } function unwrapData(res: any): any { return res?.data !== undefined ? res.data : res; } // ===== Icons ===== const IconSearch = () => ( ); const IconPlus = () => ( ); const IconMore = () => ( ); const IconSend = () => ( ); const IconChevronDown = () => ( ); const IconEdit = () => ( ); const IconArchive = () => ( ); const IconTrash = () => ( ); const IconPin = () => ( ); const IconGraph = () => ( ); const IconKnowledge = () => ( ); const IconAgent = () => ( ); const IconSkill = () => ( ); const IconModel = () => ( ); const IconEmpty = () => ( ); const IconSpinner = () => ( ); // ===== Dropdown Component ===== interface DropdownProps { trigger: React.ReactNode; children: React.ReactNode; className?: string; align?: 'left' | 'right'; dropup?: boolean; closeOnClick?: boolean; } const Dropdown: React.FC = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => { const [open, setOpen] = useState(false); const ref = useRef(null); const menuRef = useRef(null); const openRef = useRef(open); openRef.current = open; useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; // 如果点击在wrapper内部,不关闭 if (ref.current?.contains(target)) return; setOpen(false); }; document.addEventListener('mousedown', handleClickOutside, true); return () => document.removeEventListener('mousedown', handleClickOutside, true); }, []); // 打开时处理z-index useEffect(() => { if (open && dropup) { const menu = menuRef.current; if (menu) { menu.style.zIndex = '9999'; } } }, [open, dropup]); const alignClass = align === 'right' ? 'align-right' : 'align-left'; const dropupClass = dropup ? 'dropup' : ''; const handleTriggerMouseDown = (e: React.MouseEvent) => { // 阻止冒泡到document,防止被handleClickOutside误关 e.stopPropagation(); // 基于当前状态切换(在mousedown阶段决定,避免与click冲突) setOpen(prev => !prev); }; const handleTriggerClick = (e: React.MouseEvent) => { // 阻止click冒泡到父级的导航onClick e.stopPropagation(); e.preventDefault(); }; return (
    {trigger}
    {open && (
    e.stopPropagation()} onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }} > {children}
    )}
    ); }; // ===== Main Component ===== const ChatPage: React.FC = () => { const { id } = useParams<{ id?: string }>(); const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(''); const [selectedModelId, setSelectedModelId] = useState(''); const [chatModels, setChatModels] = useState([]); const [inputValue, setInputValue] = useState(''); const [sending, setSending] = useState(false); const [conversations, setConversations] = useState([]); const [messages, setMessages] = useState([]); const [loadingConversations, setLoadingConversations] = useState(true); const [loadingMessages, setLoadingMessages] = useState(false); const [knowledgeBases, setKnowledgeBases] = useState([]); const [agents, setAgents] = useState([]); const [skills, setSkills] = useState([]); // 多选组合模式 const [selectedAgentIds, setSelectedAgentIds] = useState([]); const [selectedSkillIds, setSelectedSkillIds] = useState([]); const [selectedKbIds, setSelectedKbIds] = useState([]); const messagesEndRef = useRef(null); const inputRef = useRef(null); // Knowledge graph conversion state const [kgModalOpen, setKgModalOpen] = useState(false); const [kgStep, setKgStep] = useState<1 | 2 | 3>(1); const [kgResult, setKgResult] = useState<{ nodes: number; edges: number; kbName: string; kbId: string } | null>(null); const [kgError, setKgError] = useState(null); const [kgConvId, setKgConvId] = useState(null); // Load models from backend API useEffect(() => { chatAPI.getModels().then((res: any) => { const data = res.data || res; const models = Array.isArray(data) ? data : (data?.items || []); setChatModels(models); if (models.length > 0 && !selectedModelId) { setSelectedModelId(models[0].id); } }).catch(() => { setChatModels([]); }); }, []); // Load conversations from API const loadConversations = useCallback(async () => { try { setLoadingConversations(true); const res = await chatAPI.getConversations(); const convs = unwrapList(res); // Sort: pinned first, then by updatedAt desc const sorted = [...convs].sort((a: any, b: any) => { if (a.pinned && !b.pinned) return -1; if (!a.pinned && b.pinned) return 1; return new Date(b.updatedAt || b.updated_at || 0).getTime() - new Date(a.updatedAt || a.updated_at || 0).getTime(); }); // Normalize field names const mapped: Conversation[] = sorted.map((c: any) => ({ id: c.id, userId: c.userId || c.user_id || '', title: c.title || '未命名对话', modelId: c.modelId || c.model_id || '', status: c.status || 'active', pinned: c.pinned || false, knowledgeGraphCount: c.knowledgeGraphCount ?? c.knowledge_graph_count ?? 0, lastMessage: c.lastMessage || c.last_message || '', createdAt: c.createdAt || c.created_at || new Date().toISOString(), updatedAt: c.updatedAt || c.updated_at || new Date().toISOString(), })); setConversations(mapped); } catch (err) { console.error('加载对话列表失败', err); } finally { setLoadingConversations(false); } }, []); // Load conversations on mount useEffect(() => { loadConversations(); }, [loadConversations]); // Load messages for selected conversation useEffect(() => { if (!id) { setMessages([]); return; } const loadMessages = async () => { try { setLoadingMessages(true); const res = await chatAPI.getMessages(id); const msgs = unwrapList(res); const mapped: Message[] = msgs.map((m: any) => ({ id: m.id, conversationId: m.conversationId || m.conversation_id || id, role: m.role, content: m.content, tokenUsed: m.tokenUsed ?? m.token_used ?? 0, createdAt: m.createdAt || m.created_at || new Date().toISOString(), })); setMessages(mapped); } catch (err) { console.error('加载消息失败', err); setMessages([]); } finally { setLoadingMessages(false); } }; loadMessages(); }, [id]); // Load knowledge bases, agents, skills for attachment dropdowns useEffect(() => { knowledgeAPI.getBases().then((res: any) => { const data = res?.data !== undefined ? res.data : res; if (data?.bases && Array.isArray(data.bases)) { setKnowledgeBases(data.bases); } else if (Array.isArray(data)) { setKnowledgeBases(data); } else if (data?.items) { setKnowledgeBases(data.items); } else { setKnowledgeBases([]); } }).catch(() => {}); marketAPI.getApps({ type: 'agent' }).then((res: any) => { const data = res?.data !== undefined ? res.data : res; let apps: any[] = []; if (Array.isArray(data)) apps = data; else if (data?.items) apps = data.items; // 过滤已卸载和未安装的agent setAgents(apps.filter((a: any) => a.installed !== false && a.status !== 'removed')); }).catch(() => {}); marketAPI.getApps({ type: 'skill' }).then((res: any) => { const data = res?.data !== undefined ? res.data : res; let apps: any[] = []; if (Array.isArray(data)) apps = data; else if (data?.items) apps = data.items; // 过滤已卸载和未安装的skill setSkills(apps.filter((a: any) => a.installed !== false && a.status !== 'removed')); }).catch(() => {}); }, []); // Determine selected conversation const selectedConversation = useMemo(() => { if (id) { return conversations.find(c => c.id === id) || null; } return conversations[0] || null; }, [id, conversations]); // Filter conversations by search const filteredConversations = useMemo(() => { const pinned = conversations.filter(c => c.pinned); const unpinned = conversations.filter(c => !c.pinned); if (!searchQuery.trim()) return [...pinned, ...unpinned]; const q = searchQuery.toLowerCase(); return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))]; }, [conversations, searchQuery]); // Selected model info const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' }; // Scroll to bottom when messages change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Select first conversation if no id param useEffect(() => { if (!id && conversations.length > 0) { navigate(`/chat/${conversations[0].id}`, { replace: true }); } }, [id, conversations, navigate]); // Handle new conversation const handleNewConversation = async () => { try { const res = await chatAPI.createConversation({ title: '新对话', model_id: selectedModelId }); const newConv = unwrapData(res); const conv: Conversation = { id: newConv.id, userId: newConv.userId || newConv.user_id || '', title: newConv.title || '新对话', modelId: newConv.modelId || newConv.model_id || selectedModelId, status: 'active', pinned: false, knowledgeGraphCount: 0, createdAt: newConv.createdAt || newConv.created_at || new Date().toISOString(), updatedAt: newConv.updatedAt || newConv.updated_at || new Date().toISOString(), }; setConversations(prev => [conv, ...prev]); navigate(`/chat/${conv.id}`); } catch (err: any) { alert('创建对话失败: ' + (err.message || '未知错误')); } }; // Handle rename const handleRename = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const newTitle = prompt('请输入新名称:'); if (newTitle && newTitle.trim()) { try { await chatAPI.updateConversation(convId, { title: newTitle.trim() }); setConversations(prev => prev.map(c => c.id === convId ? { ...c, title: newTitle.trim() } : c)); } catch (err: any) { alert('重命名失败: ' + (err.message || '未知错误')); } } }; // Handle archive const handleArchive = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const conv = conversations.find(c => c.id === convId); if (!conv) return; const newStatus = conv.status === 'archived' ? 'active' : 'archived'; try { await chatAPI.updateConversation(convId, { status: newStatus }); setConversations(prev => prev.map(c => c.id === convId ? { ...c, status: newStatus } as Conversation : c)); } catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); } }; // Handle delete const handleDelete = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); if (!confirm('确定要删除这个对话吗?')) return; try { await chatAPI.deleteConversation(convId); const next = conversations.filter(c => c.id !== convId); setConversations(next); if (selectedConversation?.id === convId) { if (next.length > 0) { navigate(`/chat/${next[0].id}`); } else { navigate('/chat'); } } } catch (err: any) { alert('删除失败: ' + (err.message || '未知错误')); } }; // Handle pin / unpin const handlePin = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); const conv = conversations.find(c => c.id === convId); if (!conv) return; const newPinned = !conv.pinned; try { await chatAPI.updateConversation(convId, { pinned: newPinned }); setConversations(prev => { const updated = prev.map(c => c.id === convId ? { ...c, pinned: newPinned } : c); const pinned = updated.filter(c => c.pinned); const unpinned = updated.filter(c => !c.pinned).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); return [...pinned, ...unpinned]; }); } catch (err: any) { alert('操作失败: ' + (err.message || '未知错误')); } }; // Handle convert to knowledge graph const handleKnowledgeGraph = async (convId: string, e: React.MouseEvent) => { e.stopPropagation(); setKgModalOpen(true); setKgStep(1); setKgResult(null); setKgError(null); setKgConvId(convId); try { await new Promise(r => setTimeout(r, 600)); setKgStep(2); const res = await chatAPI.convertToKnowledgeGraph(convId); await new Promise(r => setTimeout(r, 400)); setKgStep(3); if (res.code === 0) { const graph = res.data.graph; setKgResult({ nodes: graph.stats.total_nodes, edges: graph.stats.total_edges, kbName: res.data.knowledge_base_name, kbId: res.data.knowledge_base_id, }); setConversations(prev => prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: (c.knowledgeGraphCount || 0) + 1 } : c) ); } else { setKgError(res.message || '转换失败'); } } catch (err) { setKgError((err as Error).message || '转换失败,请稍后重试'); } }; // Handle send message - CALLS REAL API const handleSend = async () => { if (!inputValue.trim() || sending) return; const content = inputValue.trim(); setInputValue(''); setSending(true); // Add user message optimistically const tempUserMsg: Message = { id: `temp_${Date.now()}`, conversationId: selectedConversation?.id || '', role: 'user', content, tokenUsed: 0, createdAt: new Date().toISOString(), }; setMessages(prev => [...prev, tempUserMsg]); try { const res = await chatAPI.sendMessage(selectedConversation!.id, { content, model_id: selectedModelId || undefined, agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined, skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined, knowledge_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined, use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0, }); const data = unwrapData(res); // Replace temporary user message and add assistant response setMessages(prev => { const filtered = prev.filter(m => m.id !== tempUserMsg.id); const newMsgs: Message[] = []; if (data.user_message) { newMsgs.push({ id: data.user_message.id, conversationId: selectedConversation?.id || '', role: 'user', content: data.user_message.content, tokenUsed: data.user_message.token_used || 0, createdAt: data.user_message.created_at || new Date().toISOString(), }); } if (data.assistant_message) { newMsgs.push({ id: data.assistant_message.id, conversationId: selectedConversation?.id || '', role: 'assistant', content: data.assistant_message.content, tokenUsed: data.assistant_message.token_used || 0, createdAt: data.assistant_message.created_at || new Date().toISOString(), used_skills: data.assistant_message.used_skills || [], }); } return [...filtered, ...newMsgs]; }); // Refresh conversations to update lastMessage loadConversations(); // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI) if (selectedConversation?.id && data.assistant_message) { const recentMsgs = [ ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []), { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 }, ]; evolutionAPI.submitTrajectory({ conversation_id: selectedConversation.id, messages: recentMsgs, }).catch(() => {}); } } catch (err: any) { // Remove optimistic message on error setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); alert('发送失败: ' + (err.message || err.detail || '未知错误')); } finally { setSending(false); inputRef.current?.focus(); } }; // Handle key down const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; return (
    {/* Left Sidebar - Conversation List */} {/* Right Main Chat Area */}
    {selectedConversation ? ( <> {/* Chat Header */}
    {selectedModel.name} {(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}
    } > {chatModels.filter(m => m.is_enabled !== false).map(model => ( ))} {selectedConversation.title}
    {/* Messages Area */}
    {loadingMessages ? (

    加载消息中...

    ) : messages.length === 0 ? (

    开始一段新对话

    向 {selectedModel.name} 发送消息,开始对话

    ) : ( messages.map(msg => { // Real used_skills from API response const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : []; return (
    {msg.role === 'assistant' ? ( ) : ( {msg.content} )}
    {/* Skill usage tags */} {msg.role === 'assistant' && usedSkills.length > 0 && (
    {usedSkills.slice(0, 3).map(skillName => ( window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`} title={`使用了专属技能:${skillName}`} > {skillName} ))} {usedSkills.length > 3 && ( +{usedSkills.length - 3} )}
    )}
    {formatTime(msg.createdAt)} {msg.tokenUsed} tokens
    ); }) )} {sending && (
    思考中...
    )}
    {/* Input Area */}
    {selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'} } >
    选择知识库(多选)
    {knowledgeBases.map((kb: any) => ( ))} {knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
    )} {selectedKbIds.length > 0 && ( )} {knowledgeBases.length === 0 && (
    暂无可用知识库
    )} {selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'} } >
    选择 Agent(多选)
    {agents.map((agent: any) => ( ))} {agents.length > 0 && selectedAgentIds.length > 0 && (
    )} {selectedAgentIds.length > 0 && ( )} {agents.length === 0 && (
    暂无可用 Agent
    )} {selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'} } >
    选择技能(多选)
    {skills.map((skill: any) => ( ))} {skills.length > 0 && selectedSkillIds.length > 0 && (
    )} {selectedSkillIds.length > 0 && ( )} {skills.length === 0 && (
    暂无可用技能
    )}