Files
qihuaiflow/frontend/src/pages/front/ChatPage.tsx
T
qyyfw 78ff261b5a 初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
2026-07-02 14:40:46 +08:00

1081 lines
45 KiB
TypeScript

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 `<pre class="md-code-block"><code class="md-code">${escapeHtml(code.trim())}</code></pre>`;
});
// Inline code (`)
text = text.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>');
// Bold (**text**)
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic (*text*)
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Headers (## heading)
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// Unordered list items
text = text.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>');
// Ordered list items
text = text.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Horizontal rule
text = text.replace(/^---$/gm, '<hr class="md-hr">');
// Paragraph spacing: double newlines
text = text.replace(/\n\n/g, '<br/><br/>');
// Single newlines to <br>
text = text.replace(/\n/g, '<br/>');
return text;
}, [content]);
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
};
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ===== 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 = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
);
const IconPlus = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
);
const IconMore = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none"/>
</svg>
);
const IconSend = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
);
const IconChevronDown = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
);
const IconEdit = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
);
const IconArchive = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
</svg>
);
const IconTrash = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
);
const IconPin = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2 L12 22"/><path d="M5 6 L5 6"/><path d="M6 6 L6 12 C6 15 12 17 12 22 C12 17 18 15 18 12 L18 6"/>
</svg>
);
const IconGraph = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/>
<line x1="8" y1="6" x2="10" y2="12"/><line x1="16" y1="6" x2="14" y2="12"/><line x1="8" y1="18" x2="10" y2="12"/><line x1="16" y1="18" x2="14" y2="12"/>
</svg>
);
const IconKnowledge = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
);
const IconAgent = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M2 21c0-5.5 4.5-10 10-10s10 4.5 10 10"/>
<circle cx="12" cy="9" r="1" fill="currentColor" stroke="none"/>
</svg>
);
const IconSkill = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
);
const IconModel = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
);
const IconEmpty = () => (
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" opacity="0.3">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<line x1="9" y1="10" x2="15" y2="10"/><line x1="12" y1="7" x2="12" y2="13"/>
</svg>
);
const IconSpinner = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="spin-icon">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round"/>
</svg>
);
// ===== Dropdown Component =====
interface DropdownProps {
trigger: React.ReactNode;
children: React.ReactNode;
className?: string;
align?: 'left' | 'right';
dropup?: boolean;
closeOnClick?: boolean;
}
const Dropdown: React.FC<DropdownProps> = ({ trigger, children, className = '', align = 'left', dropup = false, closeOnClick = true }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<div className={`dropdown-wrapper ${className}`} ref={ref}>
<div
className="dropdown-trigger"
onMouseDown={handleTriggerMouseDown}
onClick={handleTriggerClick}
>
{trigger}
</div>
{open && (
<div
ref={menuRef}
className={`dropdown-menu ${alignClass} ${dropupClass}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); if (closeOnClick) setOpen(false); }}
>
{children}
</div>
)}
</div>
);
};
// ===== 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<any[]>([]);
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [loadingConversations, setLoadingConversations] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
const [knowledgeBases, setKnowledgeBases] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]);
const [skills, setSkills] = useState<any[]>([]);
// 多选组合模式
const [selectedAgentIds, setSelectedAgentIds] = useState<string[]>([]);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [selectedKbIds, setSelectedKbIds] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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<string | null>(null);
const [kgConvId, setKgConvId] = useState<string | null>(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 (
<div className="chat-page">
{/* Left Sidebar - Conversation List */}
<aside className="chat-sidebar">
<div className="chat-sidebar-header">
<button className="new-chat-btn" onClick={handleNewConversation}>
<IconPlus />
<span>新建对话</span>
</button>
</div>
<div className="chat-sidebar-search">
<span className="search-icon-wrap">
<IconSearch />
</span>
<input
type="text"
className="search-input"
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="chat-sidebar-list">
{loadingConversations ? (
<div className="conv-empty">加载中...</div>
) : (
filteredConversations.map(conv => (
<div
key={conv.id}
className={`conversation-item ${conv.id === selectedConversation?.id ? 'active' : ''} ${conv.pinned ? 'pinned' : ''}`}
onClick={() => navigate(`/chat/${conv.id}`)}
>
<div className="conv-top">
{conv.pinned && <span className="conv-pin-icon"><IconPin /></span>}
<span className="conv-title truncate">{conv.title}</span>
<Dropdown
trigger={<span className="conv-more-btn"><IconMore /></span>}
align="right"
>
<button className="dropdown-item" onClick={(e) => handlePin(conv.id, e)}>
<IconPin /> {conv.pinned ? '取消置顶' : '置顶'}
</button>
<button className="dropdown-item" onClick={(e) => handleKnowledgeGraph(conv.id, e)}>
<IconGraph /> 转知识图谱{conv.knowledgeGraphCount ? ` (${conv.knowledgeGraphCount})` : ''}
</button>
<div className="dropdown-divider" />
<button className="dropdown-item" onClick={(e) => handleRename(conv.id, e)}>
<IconEdit /> 重命名
</button>
<button className="dropdown-item" onClick={(e) => handleArchive(conv.id, e)}>
<IconArchive /> {conv.status === 'archived' ? '取消归档' : '归档'}
</button>
<div className="dropdown-divider" />
<button className="dropdown-item danger" onClick={(e) => handleDelete(conv.id, e)}>
<IconTrash /> 删除
</button>
</Dropdown>
</div>
<div className="conv-preview truncate">
{conv.lastMessage || '暂无消息'}
</div>
<div className="conv-meta">
<span className="conv-time">{formatTime(conv.updatedAt)}</span>
{conv.status === 'archived' && <span className="conv-badge">已归档</span>}
</div>
</div>
))
)}
{!loadingConversations && filteredConversations.length === 0 && (
<div className="conv-empty">
{searchQuery ? '未找到匹配的对话' : '暂无对话'}
</div>
)}
</div>
</aside>
{/* Right Main Chat Area */}
<div className="chat-main">
{selectedConversation ? (
<>
{/* Chat Header */}
<div className="chat-header">
<div className="chat-header-left">
<Dropdown
trigger={
<div className="model-selector">
<IconModel />
<span className="model-name">{selectedModel.name}</span>
<span className="model-badge">{(selectedModel.status === 'free' || selectedModel.price_per_million_tokens === 0) ? '免费' : '付费'}</span>
<IconChevronDown />
</div>
}
>
{chatModels.filter(m => m.is_enabled !== false).map(model => (
<button
key={model.id}
className={`dropdown-item ${model.id === selectedModelId ? 'selected' : ''}`}
onClick={() => setSelectedModelId(model.id)}
>
<div className="model-dropdown-item">
<span>{model.name}</span>
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span>
</div>
</button>
))}
</Dropdown>
<span className="chat-header-divider" />
<span className="chat-title">{selectedConversation.title}</span>
</div>
</div>
{/* Messages Area */}
<div className="chat-messages">
{loadingMessages ? (
<div className="chat-empty-state">
<IconEmpty />
<p className="empty-desc">加载消息中...</p>
</div>
) : messages.length === 0 ? (
<div className="chat-empty-state">
<IconEmpty />
<p className="empty-title">开始一段新对话</p>
<p className="empty-desc">
向 {selectedModel.name} 发送消息,开始对话
</p>
</div>
) : (
messages.map(msg => {
// Real used_skills from API response
const usedSkills: string[] = (msg.role === 'assistant' && msg.used_skills) ? msg.used_skills : [];
return (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className={`message-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<MarkdownContent content={msg.content} />
) : (
<span>{msg.content}</span>
)}
</div>
{/* Skill usage tags */}
{msg.role === 'assistant' && usedSkills.length > 0 && (
<div className="evo-skill-tags-row">
{usedSkills.slice(0, 3).map(skillName => (
<span
key={skillName}
className="evo-skill-tag"
onClick={() => window.location.hash = `#/evolution?skill=${encodeURIComponent(skillName)}`}
title={`使用了专属技能:${skillName}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
{skillName}
</span>
))}
{usedSkills.length > 3 && (
<span className="evo-skill-tag evo-skill-tag-more">+{usedSkills.length - 3}</span>
)}
</div>
)}
<div className={`message-meta ${msg.role}`}>
<span className="message-time">{formatTime(msg.createdAt)}</span>
<span className="message-tokens">{msg.tokenUsed} tokens</span>
</div>
</div>
);
})
)}
{sending && (
<div className="message-row assistant">
<div className="message-bubble assistant" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconSpinner />
<span>思考中...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="chat-input-area">
<div className="chat-input-container">
<div className="input-attachments">
<Dropdown
dropup
closeOnClick={false}
trigger={
<button className="attach-btn" title="知识库">
<IconKnowledge />
<span>{selectedKbIds.length > 0 ? `知识库 (${selectedKbIds.length})` : '知识库'}</span>
</button>
}
>
<div className="attach-dropdown-title">选择知识库(多选)</div>
{knowledgeBases.map((kb: any) => (
<button key={kb.id} className={`dropdown-item ${selectedKbIds.includes(kb.id) ? 'selected' : ''}`}
onClick={() => { setSelectedKbIds(prev => prev.includes(kb.id) ? prev.filter(id => id !== kb.id) : [...prev, kb.id]); }}>
<IconKnowledge />
<span>{kb.name}</span>
<span className="attach-count">{kb.documentCount ?? kb.document_count ?? 0} 文档</span>
{selectedKbIds.includes(kb.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
</button>
))}
{knowledgeBases.length > 0 && selectedKbIds.length > 0 && (
<div className="dropdown-divider" />
)}
{selectedKbIds.length > 0 && (
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedKbIds([])}>
清除选择 ({selectedKbIds.length})
</button>
)}
{knowledgeBases.length === 0 && (
<div className="dropdown-item disabled">暂无可用知识库</div>
)}
</Dropdown>
<Dropdown
dropup
closeOnClick={false}
trigger={
<button className="attach-btn" title="Agent">
<IconAgent />
<span>{selectedAgentIds.length > 0 ? `Agent (${selectedAgentIds.length})` : 'Agent'}</span>
</button>
}
>
<div className="attach-dropdown-title">选择 Agent(多选)</div>
{agents.map((agent: any) => (
<button key={agent.id} className={`dropdown-item ${selectedAgentIds.includes(agent.id) ? 'selected' : ''}`}
onClick={() => { setSelectedAgentIds(prev => prev.includes(agent.id) ? prev.filter(id => id !== agent.id) : [...prev, agent.id]); }}>
<IconAgent />
<span>{agent.name}</span>
{selectedAgentIds.includes(agent.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
</button>
))}
{agents.length > 0 && selectedAgentIds.length > 0 && (
<div className="dropdown-divider" />
)}
{selectedAgentIds.length > 0 && (
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedAgentIds([])}>
清除选择 ({selectedAgentIds.length})
</button>
)}
{agents.length === 0 && (
<div className="dropdown-item disabled">暂无可用 Agent</div>
)}
</Dropdown>
<Dropdown
dropup
closeOnClick={false}
trigger={
<button className="attach-btn" title="技能">
<IconSkill />
<span>{selectedSkillIds.length > 0 ? `技能 (${selectedSkillIds.length})` : '技能'}</span>
</button>
}
>
<div className="attach-dropdown-title">选择技能(多选)</div>
{skills.map((skill: any) => (
<button key={skill.id} className={`dropdown-item ${selectedSkillIds.includes(skill.id) ? 'selected' : ''}`}
onClick={() => { setSelectedSkillIds(prev => prev.includes(skill.id) ? prev.filter(id => id !== skill.id) : [...prev, skill.id]); }}>
<IconSkill />
<span>{skill.name}</span>
{selectedSkillIds.includes(skill.id) && <span style={{marginLeft:'auto',color:'var(--color-accent)'}}>✓</span>}
</button>
))}
{skills.length > 0 && selectedSkillIds.length > 0 && (
<div className="dropdown-divider" />
)}
{selectedSkillIds.length > 0 && (
<button className="dropdown-item" style={{color:'var(--color-text-tertiary)'}} onClick={() => setSelectedSkillIds([])}>
清除选择 ({selectedSkillIds.length})
</button>
)}
{skills.length === 0 && (
<div className="dropdown-item disabled">暂无可用技能</div>
)}
</Dropdown>
</div>
<div className="input-row">
<textarea
ref={inputRef}
className="chat-textarea"
placeholder={`向 ${selectedModel.name} 发送消息...`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={sending}
/>
<button
className="send-btn"
onClick={handleSend}
disabled={!inputValue.trim() || sending}
title="发送 (Enter)"
>
{sending ? <IconSpinner /> : <IconSend />}
</button>
</div>
</div>
</div>
</>
) : (
<div className="chat-empty-full">
<IconEmpty />
<p className="empty-title">选择或创建对话</p>
<p className="empty-desc">从左侧列表选择一个对话,或点击上方按钮创建新对话</p>
</div>
)}
</div>
{/* Knowledge Graph Conversion Progress Modal */}
{kgModalOpen && (
<div className="kg-overlay" onClick={() => !kgError && kgStep !== 2 && setKgModalOpen(false)}>
<div className="kg-modal" onClick={e => e.stopPropagation()}>
<div className="kg-modal-header">
<h3>转换知识图谱</h3>
<button className="kg-modal-close" onClick={() => setKgModalOpen(false)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div className="kg-modal-body">
{kgError ? (
<div className="kg-error-state">
<div className="kg-error-icon">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="var(--color-error)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<p className="kg-error-text">{kgError}</p>
<button className="kg-btn kg-btn-secondary" onClick={() => { setKgModalOpen(false); setKgError(null); }}>
关闭
</button>
</div>
) : kgStep === 3 && kgResult ? (
<div className="kg-success-state">
<div className="kg-success-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<p className="kg-success-title">知识图谱生成完成</p>
<div className="kg-result-stats">
<div className="kg-stat-item">
<span className="kg-stat-value">{kgResult.nodes}</span>
<span className="kg-stat-label">节点</span>
</div>
<div className="kg-stat-divider" />
<div className="kg-stat-item">
<span className="kg-stat-value">{kgResult.edges}</span>
<span className="kg-stat-label">条边</span>
</div>
</div>
<p className="kg-result-kb">已存入知识库:{kgResult.kbName}</p>
<div className="kg-actions">
<button className="kg-btn kg-btn-primary" onClick={() => { setKgModalOpen(false); navigate('/knowledge'); }}>
前往知识库查看
</button>
<button className="kg-btn kg-btn-secondary" onClick={() => setKgModalOpen(false)}>
关闭
</button>
</div>
</div>
) : (
<div className="kg-progress-state">
<div className="kg-steps">
<div className={`kg-step ${kgStep >= 1 ? 'active' : ''} ${kgStep > 1 ? 'done' : ''}`}>
<div className="kg-step-indicator">
{kgStep > 1 ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : kgStep === 1 ? (
<div className="kg-spinner" />
) : (
<span>1</span>
)}
</div>
<span className="kg-step-label">提取对话内容</span>
</div>
<div className="kg-step-connector">
<div className={`kg-connector-line ${kgStep > 1 ? 'done' : ''}`}>
{kgStep === 2 && <div className="kg-connector-progress" />}
</div>
</div>
<div className={`kg-step ${kgStep >= 2 ? 'active' : ''} ${kgStep > 2 ? 'done' : ''}`}>
<div className="kg-step-indicator">
{kgStep > 2 ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
) : kgStep === 2 ? (
<div className="kg-spinner" />
) : (
<span>2</span>
)}
</div>
<span className="kg-step-label">构建知识图谱</span>
</div>
<div className="kg-step-connector">
<div className={`kg-connector-line ${kgStep > 2 ? 'done' : ''}`} />
</div>
<div className={`kg-step ${kgStep >= 3 ? 'active' : ''} ${kgStep > 3 ? 'done' : ''}`}>
<div className="kg-step-indicator">
<span>3</span>
</div>
<span className="kg-step-label">完成</span>
</div>
</div>
<p className="kg-progress-tip">
{kgStep === 1 ? '正在提取对话内容...' : kgStep === 2 ? '正在构建知识图谱...' : '处理完成'}
</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ChatPage;