Files
deerflow-code/web-code/components/agent/AgentChatPage.tsx
T
2026-05-28 18:29:37 +08:00

1049 lines
39 KiB
TypeScript

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<string, unknown> => 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<string>;
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 (
<div>
<button
type="button"
onClick={() => (isFolder ? onToggle(node.path) : onSelect(node))}
className={`group flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-left text-sm transition-all duration-200 ${isSelected
? isDark
? 'bg-blue-500/20 text-blue-200 ring-1 ring-blue-400/40 shadow-sm'
: 'bg-blue-50 text-blue-700 ring-1 ring-blue-200 shadow-sm'
: isDark
? 'text-slate-300 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-950'
}`}
style={{ paddingLeft: `${10 + depth * 18}px` }}
title={node.path}
>
<span className="flex w-4 shrink-0 justify-center">
{isFolder && hasChildren ? (
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />
) : null}
</span>
<span
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg ${isFolder
? isDark ? 'bg-amber-500/15 text-amber-300' : 'bg-amber-100 text-amber-600'
: isDark ? 'bg-slate-700 text-slate-300' : 'bg-white text-gray-500 shadow-sm ring-1 ring-gray-200'
}`}
>
{isFolder ? <FolderOpen className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{node.name}</span>
{!isFolder && node.size !== null && (
<span className={`shrink-0 text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{formatFileSize(node.size)}</span>
)}
</button>
{isFolder && isExpanded && hasChildren && (
<div className="mt-1 space-y-1">
{node.children!.map((child) => (
<FileTreeItem
key={child.path}
node={child}
depth={depth + 1}
isDark={isDark}
expandedPaths={expandedPaths}
selectedPath={selectedPath}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
)}
</div>
);
}
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<Agent>(propAgent || defaultAgent);
const [currentThreadId, setCurrentThreadId] = useState(() => localStorage.getItem(threadStorageKey) || '');
const [inputValue, setInputValue] = useState('');
const [messages, setMessages] = useState<ConversationMessage[]>([]);
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<FileNode[]>([]);
const [fileListThreadId, setFileListThreadId] = useState<number | null>(null);
const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(false);
const [fileSidebarWidth, setFileSidebarWidth] = useState(DEFAULT_FILE_SIDEBAR_WIDTH);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
const [selectedFile, setSelectedFile] = useState<FileNode | null>(null);
const [filePreviewContent, setFilePreviewContent] = useState('');
const [isFilePreviewLoading, setIsFilePreviewLoading] = useState(false);
const [filePreviewError, setFilePreviewError] = useState('');
const scrollContainerRef = useRef<HTMLDivElement>(null);
const thinkingStartRef = useRef<number>(0);
const abortControllerRef = useRef<AbortController | null>(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<string, unknown>;
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<Record<string, unknown>> = [];
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div className={`flex h-[calc(100vh-80px)] w-full flex-col ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
<div className={`border-b px-6 py-4 ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/agent-expert-center')}
className={`p-1.5 rounded-md ${isDark ? 'hover:bg-slate-800 text-slate-300' : 'hover:bg-gray-100 text-gray-600'}`}
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 text-sm font-semibold text-white">
{agent.name.charAt(0)}
</div>
<div>
<h2 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{agent.name}
</h2>
<p className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsThreadSidebarOpen((prev) => !prev)}
className={`inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${isDark
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-gray-200 bg-gray-50 text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
}`}
title="打开聊天记录"
>
<MessageSquareText className="h-4 w-4" />
聊天记录
</button>
<button
onClick={() => setIsFileSidebarOpen(true)}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-400 via-blue-500 to-purple-600 text-white transition-all duration-300 hover:scale-110 hover:shadow-lg hover:shadow-blue-500/50"
title="文件"
>
<FolderOpen className="h-5 w-5" />
</button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 overflow-hidden">
<SkillCreatorThreadSidebar
authedFetch={authedFetch}
currentThreadId={currentThreadId}
isDarkMode={isDark}
isOpen={isThreadSidebarOpen}
isBusy={isStreaming}
createButtonText="新建聊天"
emptyText="当前专家暂无聊天记录"
subtitle="仅展示当前专家的聊天记录"
createThreadPayload={{
title: `与 ${agent.name} 的对话`,
expert_agent_id: agentId ? Number(agentId) : null,
}}
threadFilter={(thread) => String(thread.expert_agent_id ?? '') === String(agentId ?? '')}
onClose={() => setIsThreadSidebarOpen(false)}
onThreadSelect={handleThreadSelect}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="relative flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-8"
>
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载对话中...</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center">
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 text-white shadow-lg shadow-violet-300/30">
<Zap className="h-10 w-10" />
</div>
<h3 className={`mb-2 text-2xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
你好,我是 {agent.name}
</h3>
<p className={`mb-6 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.description}</p>
<div className={`mb-8 inline-flex items-center gap-2 rounded-full border px-4 py-2 ${isDark ? 'border-emerald-800 bg-emerald-950/50 text-emerald-300' : 'border-emerald-200 bg-emerald-50 text-emerald-700'}`}>
<span className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-xs font-medium">{agent.status}</span>
</div>
<div className="flex flex-wrap justify-center gap-3">
{agent.capabilities?.map((capability, idx) => (
<button
key={idx}
onClick={() => handleCapabilityClick(capability)}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${isDark
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{capability}
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<AgentConversationList messages={messages} isConnecting={isStreaming} />
</div>
)}
{/* {showScrollButton && (
<button
onClick={() => {
setAutoScrollEnabled(true);
scrollToBottom(true);
}}
className={`absolute bottom-20 left-1/2 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border text-xs font-medium transition ${
isDark
? 'border-slate-200/25 bg-slate-300/25 text-slate-100 hover:bg-slate-300/35'
: 'border-gray-200/70 bg-gray-300/45 text-gray-700 hover:bg-gray-300/60'
}`}
aria-label="自动下滑"
title="自动下滑"
>
<ChevronDown className="h-4 w-4" />
</button>
)} */}
</div>
<ChatInputComposer
value={inputValue}
onChange={setInputValue}
onSend={() => void handleSendMessage()}
onStop={handleStopStream}
onUploadFile={(file) => void handleUploadFile(file)}
isSending={isStreaming}
isDarkMode={isDark}
showBorderTop
placeholder="输入你的问题..."
/>
</div>
{isFileSidebarOpen && (
<aside
className={`relative flex min-h-0 shrink-0 border-l shadow-2xl ${isDark ? 'border-slate-700 bg-slate-950 text-slate-100 shadow-black/30' : 'border-gray-200 bg-white text-gray-900 shadow-gray-200/70'
}`}
style={{ width: fileSidebarWidth }}
>
<div
onMouseDown={handleResizeStart}
className={`absolute left-0 top-0 z-20 h-full w-2 -translate-x-1/2 cursor-col-resize transition-colors ${isDark ? 'hover:bg-blue-400/40' : 'hover:bg-blue-500/25'
}`}
title="拖拽调整文件侧边栏宽度"
/>
<div className={`flex w-[46%] min-w-[220px] flex-col border-r ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
<div className={`border-b px-4 py-3 ${isDark ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-gray-50/80'}`}>
<div>
<h3 className="text-sm font-semibold">文件</h3>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>{fileList.length} 个根节点</p>
</div>
</div>
<div className="agent-file-tree-scroll min-h-0 flex-1 overflow-y-auto p-3">
{fileList.length > 0 ? (
<div className="space-y-1">
{fileList.map((node) => (
<FileTreeItem
key={node.path}
node={node}
isDark={isDark}
expandedPaths={expandedPaths}
selectedPath={selectedFile?.path ?? null}
onToggle={handleToggleFolder}
onSelect={(file) => void handleSelectFile(file)}
/>
))}
</div>
) : (
<div className={`rounded-2xl border border-dashed px-4 py-8 text-center text-sm ${isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'}`}>
暂无文件
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div className={`flex items-center justify-between gap-3 border-b px-5 py-3 ${isDark ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-gray-50/80'}`}>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold">{selectedFile?.name ?? '文件预览'}</h3>
<p className={`mt-0.5 truncate text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
{selectedFile ? selectedFile.path : '选择左侧文件查看内容'}
</p>
</div>
<button
type="button"
onClick={() => setIsFileSidebarOpen(false)}
className={`flex shrink-0 items-center justify-center rounded-lg transition ${isDark ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-900'
}`}
title="关闭文件侧边栏"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="agent-file-preview-scroll min-h-0 flex-1 overflow-auto p-5">
{!selectedFile ? (
<div className={`flex h-full flex-col items-center justify-center rounded-3xl border border-dashed text-center ${isDark ? 'border-slate-800 bg-slate-900/40 text-slate-500' : 'border-gray-200 bg-gray-50 text-gray-400'}`}>
<FileText className="mb-3 h-10 w-10 opacity-60" />
<p className="text-sm font-medium">请选择一个文件</p>
<p className="mt-1 text-xs">文件内容会显示在这里</p>
</div>
) : isFilePreviewLoading ? (
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
加载文件内容中...
</div>
) : filePreviewError ? (
<div className={`flex h-full flex-col items-center justify-center gap-2 text-sm ${isDark ? 'text-red-300' : 'text-red-500'}`}>
<AlertCircle className="h-8 w-8" />
{filePreviewError}
</div>
) : (
<div className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-800 bg-slate-900' : 'border-gray-200 bg-white'}`}>
<div className={`flex items-center justify-between border-b px-4 py-2.5 ${isDark ? 'border-slate-800 bg-slate-950/70' : 'border-gray-100 bg-gray-50'}`}>
<span className={`text-xs font-semibold tracking-wide ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{getFileLanguage(selectedFile.extension)}</span>
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>{formatFileSize(selectedFile.size)}</span>
</div>
<div className={`p-5 text-sm leading-7 ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
<MarkdownRenderer
content={filePreviewContent || '文件内容为空'}
className="agent-file-markdown-preview"
/>
</div>
</div>
)}
</div>
</div>
</aside>
)}
</div>
</div>
);
}