import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { AlertCircle, ArrowLeft, ChevronDown, ChevronRight, FileText, FolderOpen, Loader2, MessageSquareText, X, Zap } from 'lucide-react'; import './AgentChatPage.css'; import { useTheme } from '../../contexts/ThemeContext'; import { useExpertAuthBootstrap } from '../../hooks'; import MarkdownRenderer from '../MarkdownRenderer'; import AgentConversationList, { type ConversationMessage, type ConversationStep } from './AgentConversationList'; import ChatInputComposer from './ChatInputComposer'; import SkillCreatorThreadSidebar from './SkillCreatorThreadSidebar'; import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap'; interface FileNode { name: string; path: string; type: 'file' | 'directory'; size: number | null; extension: string | null; children: FileNode[] | null; } interface Agent { id: string; name: string; description: string; status: string; icon?: string; capabilities?: string[]; } interface HistoryMessageApiItem { created_at: string; id: number; role: 'user' | 'assistant'; content: unknown; thread_id: number; } const defaultAgent: Agent = { id: '1', name: '测试1', description: '描述01', status: '随时准备为您服务', capabilities: ['告诉我的能力', '给我一些建议', '帮我分析问题'], }; const MIN_CHAT_WIDTH = 360; const MIN_FILE_SIDEBAR_WIDTH = 420; const DEFAULT_FILE_SIDEBAR_WIDTH = 560; const formatFileSize = (size: number | null) => { if (!size) return ''; if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1024 / 1024).toFixed(1)} MB`; }; const getFileLanguage = (extension: string | null) => { const normalized = extension?.replace('.', '').toLowerCase(); if (!normalized) return 'TEXT'; return normalized.toUpperCase(); }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const normalizeHistoryContent = (content: unknown) => { const steps: ConversationStep[] = []; const thinkings: Array<{ duration: number; content: string }> = []; const texts: string[] = []; const visit = (value: unknown) => { if (typeof value === 'string') { if (value.trim()) texts.push(value); return; } if (Array.isArray(value)) { value.forEach(visit); return; } if (!isRecord(value)) return; const type = typeof value.type === 'string' ? value.type : ''; const text = typeof value.text === 'string' ? value.text : ''; if (type === 'thinking-steps' && Array.isArray(value.steps)) { value.steps.forEach((stepItem) => { if (!isRecord(stepItem)) return; const title = typeof stepItem.title === 'string' ? stepItem.title : '处理中'; const status = stepItem.status === 'completed' ? 'done' : 'processing'; const items = Array.isArray(stepItem.items) ? stepItem.items.filter((item): item is string => typeof item === 'string') : []; steps.push({ text: title, subText: items[0]?.replace('正在处理: ', ''), status, }); }); return; } if (type === 'reasoning' && text) { const duration = typeof value.duration === 'number' ? value.duration / 1000 : 0; thinkings.push({ duration, content: text }); return; } if (text) { texts.push(text); return; } if ('content' in value) { visit(value.content); } }; visit(content); return { content: texts.join('\n\n').trim(), steps, thinkings, }; }; const toConversationMessage = (message: HistoryMessageApiItem): ConversationMessage => { const normalized = normalizeHistoryContent(message.content); return { id: String(message.id), role: message.role, content: normalized.content, steps: normalized.steps, thinkings: normalized.thinkings, toolCalls: [], }; }; interface FileTreeItemProps { node: FileNode; depth?: number; isDark: boolean; expandedPaths: Set; selectedPath: string | null; onToggle: (path: string) => void; onSelect: (node: FileNode) => void; } function FileTreeItem({ node, depth = 0, isDark, expandedPaths, selectedPath, onToggle, onSelect, }: FileTreeItemProps) { const isFolder = node.extension === null; const isExpanded = expandedPaths.has(node.path); const isSelected = selectedPath === node.path; const hasChildren = Boolean(node.children?.length); return (
{isFolder && isExpanded && hasChildren && (
{node.children!.map((child) => ( ))}
)}
); } interface AgentChatPageProps { agent?: Agent; } export default function AgentChatPage({ agent: propAgent }: AgentChatPageProps) { const { theme } = useTheme(); const isDark = theme === 'dark'; const { authedFetch } = useExpertAuthBootstrap(); const [searchParams] = useSearchParams(); const agentId = searchParams.get('id'); const threadStorageKey = `agent_chat_thread_id_${agentId ?? 'default'}`; const [agent, setAgent] = useState(propAgent || defaultAgent); const [currentThreadId, setCurrentThreadId] = useState(() => localStorage.getItem(threadStorageKey) || ''); const [inputValue, setInputValue] = useState(''); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [isThreadSidebarOpen, setIsThreadSidebarOpen] = useState(false); const [autoScrollEnabled, setAutoScrollEnabled] = useState(true); const [showScrollButton, setShowScrollButton] = useState(false); const [fileList, setFileList] = useState([]); const [fileListThreadId, setFileListThreadId] = useState(null); const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false); const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH); const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); const [selectedFile, setSelectedFile] = useState(null); const [filePreviewContent, setFilePreviewContent] = useState(''); const [isFilePreviewLoading, setIsFilePreviewLoading] = useState(false); const [filePreviewError, setFilePreviewError] = useState(''); const scrollContainerRef = useRef(null); const thinkingStartRef = useRef(0); const abortControllerRef = useRef(null); const navigate = useNavigate(); useEffect(() => { setCurrentThreadId(localStorage.getItem(threadStorageKey) || ''); }, [threadStorageKey]); useEffect(() => { const fetchAgentDetail = async () => { if (!agentId) return; setIsLoading(true); try { const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${agentId}`, { method: 'GET', }); if (!res.ok) { console.error('获取专家信息失败:', res.status, res.statusText); return; } const data: unknown = await res.json(); const agentDetail = data as { id: number; name?: string; description?: string | null; }; const agentName = agentDetail.name || '未命名专家'; setAgent({ id: String(agentDetail.id), name: agentName, description: agentDetail.description || '暂无描述', status: '随时准备为您服务', capabilities: ['告诉我的能力', '给我一些建议', '帮我分析问题'], }); } catch (error) { console.error('初始化专家信息异常:', error); } finally { setIsLoading(false); } }; void fetchAgentDetail(); }, [agentId, authedFetch]); useEffect(() => { if (!fileList.length) return; setExpandedPaths((prev) => { if (prev.size > 0) return prev; const firstLevelFolders = fileList.filter((node) => node.extension === null).map((node) => node.path); return new Set(firstLevelFolders); }); }, [fileList]); const clearLoadingStates = () => { setMessages((prev) => prev.map((msg) => { if (msg.role === 'assistant') { return { ...msg, steps: [], thinkings: [], toolCalls: [], }; } return msg; }), ); }; const syncThreadId = (nextThreadId: string) => { setCurrentThreadId(nextThreadId); if (nextThreadId) { localStorage.setItem(threadStorageKey, nextThreadId); } else { localStorage.removeItem(threadStorageKey); } }; const fetchThreadFiles = async (id: number) => { if (!id) { setFileList([]); setFileListThreadId(null); return; } try { const filesRes = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/files/all`, { method: 'GET' }, ); if (filesRes.ok) { const filesData: unknown = await filesRes.json(); setFileList(Array.isArray(filesData) ? (filesData as FileNode[]) : []); setFileListThreadId(id); } else { setFileList([]); setFileListThreadId(null); } } catch (e) { console.error('获取文件列表失败:', e); setFileList([]); setFileListThreadId(null); } }; const fetchThreadMessages = async (id: number) => { setIsLoading(true); try { const response = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${id}/messages`, { method: 'GET' }, ); if (!response.ok) { throw new Error(`获取会话消息失败: ${response.status}`); } const data = (await response.json()) as HistoryMessageApiItem[]; const nextMessages = Array.isArray(data) ? data.map(toConversationMessage) : []; setMessages(nextMessages); } catch (error) { console.error('获取聊天记录消息失败:', error); setMessages([]); } finally { setIsLoading(false); } }; useEffect(() => { const id = Number(currentThreadId); setSelectedFile(null); setFilePreviewContent(''); setFilePreviewError(''); setExpandedPaths(new Set()); if (!id) { setMessages([]); setFileList([]); setFileListThreadId(null); return; } void fetchThreadMessages(id); void fetchThreadFiles(id); }, [currentThreadId]); const handleStopStream = () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsStreaming(false); clearLoadingStates(); } }; const handleUploadFile = async (file: File) => { if (!currentThreadId) return; try { const formData = new FormData(); formData.append('file', file); const response = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${currentThreadId}/files/upload`, { method: 'POST', body: formData, }, ); if (!response.ok) { throw new Error(`上传失败: ${response.status} ${response.statusText}`); } await fetchThreadFiles(Number(currentThreadId)); } catch (error) { console.error('上传专家会话文件失败:', error); } }; const handleSendMessage = async () => { const text = inputValue.trim(); const numericThreadId = Number(currentThreadId); if (!text || !numericThreadId || isStreaming) return; const assistantId = `assistant-${Date.now()}`; setInputValue(''); setIsStreaming(true); setAutoScrollEnabled(true); abortControllerRef.current = new AbortController(); try { const userMsg: ConversationMessage = { id: `user-${Date.now()}`, role: 'user', content: text, }; setMessages((prev) => [ ...prev, userMsg, { id: assistantId, role: 'assistant', content: '', steps: [], thinkings: [], toolCalls: [] }, ]); const msgRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ thread_id: numericThreadId, role: 'user', content: { type: 'text', text }, }), }); if (!msgRes.ok) { console.error('发送消息失败:', msgRes.status, msgRes.statusText); setMessages((prev) => prev.filter((msg) => msg.id !== assistantId)); return; } const streamRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/run/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ chat_id: numericThreadId, expert_agent_id: Number(agentId), user_query: text, messages: [{ role: 'user', content: text }], }), signal: abortControllerRef.current.signal, }); if (!streamRes.ok || !streamRes.body) { console.error('流式接口失败:', streamRes.status); setMessages((prev) => prev.filter((msg) => msg.id !== assistantId)); return; } const reader = streamRes.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; const thinkingStepsAcc: Array<{ id: string; title: string; status: string; items: string[] }> = []; const reasoningsAcc: Array<{ text: string; duration: number }> = []; let assistantTextAcc = ''; let reasoningStartAt = 0; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const raw = line.slice(6).trim(); if (raw === '[DONE]') break; let event: { type: string; data?: { id: string; title: string; status: string; items?: string[] }; delta?: string; toolCallId?: string; toolName?: string; input?: Record; output?: { result?: string } | unknown; } | null = null; try { event = JSON.parse(raw); } catch { continue; } if (!event) continue; switch (event.type) { case 'data-thinking-step': { const step = event.data!; const stepData = { id: step.id, title: step.title, status: step.status, items: step.items ?? [], }; const existingIdx = thinkingStepsAcc.findIndex((s) => s.id === step.id); if (existingIdx >= 0) thinkingStepsAcc[existingIdx] = stepData; else thinkingStepsAcc.push(stepData); break; } case 'reasoning-start': { reasoningStartAt = Date.now(); reasoningsAcc.push({ text: '', duration: 0 }); break; } case 'reasoning-delta': { if (reasoningsAcc.length === 0) { reasoningsAcc.push({ text: event.delta ?? '', duration: 0 }); } else { reasoningsAcc[reasoningsAcc.length - 1].text += event.delta ?? ''; } break; } case 'reasoning-end': { if (reasoningsAcc.length > 0) { reasoningsAcc[reasoningsAcc.length - 1].duration = Date.now() - reasoningStartAt; } break; } case 'text-delta': { assistantTextAcc += event.delta ?? ''; break; } } setMessages((prev) => prev.map((msg) => { if (msg.id !== assistantId) return msg; switch (event!.type) { case 'data-thinking-step': { const step = event!.data!; const newStep = { text: step.title, subText: step.items?.[0]?.replace('正在处理: ', '') ?? undefined, status: (step.status === 'completed' ? 'done' : 'processing') as 'done' | 'processing', }; const steps = [...(msg.steps ?? [])]; const stepIdx = steps.findIndex((s) => s.text === step.title); if (stepIdx >= 0) steps[stepIdx] = newStep; else steps.push(newStep); return { ...msg, steps }; } case 'reasoning-start': { thinkingStartRef.current = Date.now(); return { ...msg, thinkings: [...(msg.thinkings ?? []), { duration: 0, content: '' }] }; } case 'reasoning-delta': { const thinkings = [...(msg.thinkings ?? [])]; if (thinkings.length === 0) { thinkings.push({ duration: 0, content: event!.delta ?? '' }); } else { const last = thinkings[thinkings.length - 1]; thinkings[thinkings.length - 1] = { ...last, content: last.content + (event!.delta ?? '') }; } return { ...msg, thinkings }; } case 'reasoning-end': { const duration = (Date.now() - thinkingStartRef.current) / 1000; const thinkings = [...(msg.thinkings ?? [])]; if (thinkings.length > 0) { thinkings[thinkings.length - 1] = { ...thinkings[thinkings.length - 1], duration }; } return { ...msg, thinkings }; } case 'tool-input-available': { const toolCalls = [...(msg.toolCalls ?? [])]; const tcIdx = toolCalls.findIndex((t) => t.id === event!.toolCallId); const newTc = { id: event!.toolCallId!, name: event!.toolName!, input: event!.input }; if (tcIdx >= 0) toolCalls[tcIdx] = { ...toolCalls[tcIdx], ...newTc }; else toolCalls.push(newTc); return { ...msg, toolCalls }; } case 'tool-output-available': { const toolCalls = [...(msg.toolCalls ?? [])]; const tcIdx = toolCalls.findIndex((t) => t.id === event!.toolCallId); if (tcIdx >= 0) { const rawOutput = event!.output; const outputStr = typeof rawOutput === 'object' && rawOutput !== null && 'result' in rawOutput ? String((rawOutput as { result: unknown }).result) : JSON.stringify(rawOutput); toolCalls[tcIdx] = { ...toolCalls[tcIdx], output: outputStr }; } return { ...msg, toolCalls }; } case 'text-delta': { return { ...msg, content: msg.content + (event!.delta ?? '') }; } default: return msg; } }), ); } } const persistedContent: Array> = []; if (thinkingStepsAcc.length > 0) { persistedContent.push({ type: 'thinking-steps', steps: thinkingStepsAcc }); } for (const reasoning of reasoningsAcc) { if (reasoning.text) { persistedContent.push({ type: 'reasoning', text: reasoning.text, duration: reasoning.duration }); } } if (assistantTextAcc) { persistedContent.push({ type: 'text', text: assistantTextAcc }); } if (persistedContent.length > 0) { const persistRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ thread_id: numericThreadId, role: 'assistant', content: persistedContent, }), }); if (!persistRes.ok) { console.error('保存助手消息失败:', persistRes.status, persistRes.statusText); } } await fetchThreadFiles(numericThreadId); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { console.log('流已被取消'); } else { console.error('对话异常:', error); } } finally { setIsStreaming(false); abortControllerRef.current = null; } }; const handleCapabilityClick = (capability: string) => { setInputValue(capability); }; const handleToggleFolder = (path: string) => { setExpandedPaths((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }; const handleSelectFile = async (file: FileNode) => { if (!fileListThreadId) { setFilePreviewError('当前文件列表还没有关联到可预览的会话'); return; } setSelectedFile(file); setFilePreviewContent(''); setFilePreviewError(''); setIsFilePreviewLoading(true); try { const previewRes = await authedFetch( `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${fileListThreadId}/files/preview?path=${encodeURIComponent(file.path)}`, { method: 'GET' }, ); if (!previewRes.ok) { setFilePreviewError(`文件预览获取失败,状态码 ${previewRes.status}`); return; } const contentType = previewRes.headers.get('content-type') ?? ''; if (contentType.includes('application/json')) { const data: unknown = await previewRes.json(); if (typeof data === 'string') { setFilePreviewContent(data); } else if (data && typeof data === 'object' && 'content' in data) { setFilePreviewContent(String((data as { content: unknown }).content ?? '')); } else { setFilePreviewContent(JSON.stringify(data, null, 2)); } } else { setFilePreviewContent(await previewRes.text()); } } catch (error) { console.error('获取文件预览失败:', error); setFilePreviewError('文件预览获取失败,请稍后重试'); } finally { setIsFilePreviewLoading(false); } }; const handleResizeStart = (event: React.MouseEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = fileSidebarWidth; const handleMouseMove = (moveEvent: MouseEvent) => { const containerWidth = window.innerWidth - 160; const maxWidth = Math.max(MIN_FILE_SIDEBAR_WIDTH, containerWidth - MIN_CHAT_WIDTH); const nextWidth = Math.min(Math.max(startWidth - (moveEvent.clientX - startX), MIN_FILE_SIDEBAR_WIDTH), maxWidth); setFileSidebarWidth(nextWidth); }; const handleMouseUp = () => { document.body.classList.remove('agent-resizing'); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; document.body.classList.add('agent-resizing'); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); }; const scrollToBottom = (smooth = true) => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: smooth ? 'smooth' : 'auto', }); } }; const handleScroll = (e: React.UIEvent) => { const container = e.currentTarget; const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; setShowScrollButton(!isAtBottom && messages.length > 0); if (!isAtBottom) { setAutoScrollEnabled(false); } }; useEffect(() => { if (autoScrollEnabled && messages.length > 0) { const timer = setTimeout(() => scrollToBottom(false), 100); return () => clearTimeout(timer); } }, [messages, autoScrollEnabled]); const handleThreadSelect = (nextThreadId: string) => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsStreaming(false); clearLoadingStates(); } syncThreadId(nextThreadId); }; return (
{agent.name.charAt(0)}

{agent.name}

{agent.description}

String(thread.expert_agent_id ?? '') === String(agentId ?? '')} onClose={() => setIsThreadSidebarOpen(false)} onThreadSelect={handleThreadSelect} />
{isLoading ? (
加载对话中...
) : messages.length === 0 ? (

你好,我是 {agent.name}

{agent.description}

{agent.status}
{agent.capabilities?.map((capability, idx) => ( ))}
) : (
)} {/* {showScrollButton && ( )} */}
void handleSendMessage()} onStop={handleStopStream} onUploadFile={(file) => void handleUploadFile(file)} isSending={isStreaming} isDarkMode={isDark} showBorderTop placeholder="输入你的问题..." />
{isFileSidebarOpen && ( )}
); }