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 && (
暂无可用技能
)}
>
) : (
选择或创建对话
从左侧列表选择一个对话,或点击上方按钮创建新对话
)}
{/* Knowledge Graph Conversion Progress Modal */}
{kgModalOpen && (
!kgError && kgStep !== 2 && setKgModalOpen(false)}>
e.stopPropagation()}>
转换知识图谱
{kgError ? (
{kgError}
) : kgStep === 3 && kgResult ? (
知识图谱生成完成
{kgResult.nodes}
节点
{kgResult.edges}
条边
已存入知识库:{kgResult.kbName}
) : (
= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
{kgStep > 1 ? (
) : kgStep === 1 ? (
) : (
1
)}
提取对话内容
1 ? 'done' : ''}`}>
{kgStep === 2 &&
}
= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
{kgStep > 2 ? (
) : kgStep === 2 ? (
) : (
2
)}
构建知识图谱
2 ? 'done' : ''}`} />
= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
3
完成
{kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
)}
)}
);
};
export default ChatPage;