- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
1081 lines
45 KiB
TypeScript
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, '&')
|
|
.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 = () => (
|
|
<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;
|