1049 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|