Files
deerflow-code/web-code/components/agent/SkillCreatorThreadSidebar.tsx
T
2026-05-27 19:43:15 +08:00

338 lines
13 KiB
TypeScript

import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
import { Archive, Loader2, MessageSquareText, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export interface SkillCreatorThread {
id: number;
title: string;
archived: boolean;
expert_agent_id: number | null;
created_at: string;
updated_at: string;
}
interface ThreadListResponse {
threads?: SkillCreatorThread[];
archived_threads?: SkillCreatorThread[];
}
interface SkillCreatorThreadSidebarProps {
authedFetch: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>;
currentThreadId: string;
isDarkMode: boolean;
isOpen: boolean;
isBusy?: boolean;
createThreadPayload?: Record<string, unknown>;
createButtonText?: string;
emptyText?: string;
subtitle?: string;
threadFilter?: (thread: SkillCreatorThread) => boolean;
onClose: () => void;
onThreadSelect: (threadId: string) => void;
}
const THREADS_API_URL = `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`;
const sortThreads = (threads: SkillCreatorThread[]) =>
[...threads].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
export default function SkillCreatorThreadSidebar({
authedFetch,
currentThreadId,
isDarkMode,
isOpen,
isBusy = false,
createThreadPayload,
createButtonText = '新建聊天',
emptyText = '暂无记录',
subtitle = '切换或管理当前会话',
threadFilter,
onClose,
onThreadSelect,
}: SkillCreatorThreadSidebarProps) {
const [threads, setThreads] = useState<SkillCreatorThread[]>([]);
const [archivedThreads, setArchivedThreads] = useState<SkillCreatorThread[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
const [error, setError] = useState('');
const applyFilter = useCallback(
(items: SkillCreatorThread[]) => (threadFilter ? items.filter(threadFilter) : items),
[threadFilter],
);
const refreshThreads = useCallback(async () => {
setIsLoading(true);
setError('');
try {
const response = await authedFetch(THREADS_API_URL, { method: 'GET' });
if (!response.ok) {
throw new Error(`获取会话列表失败: ${response.status}`);
}
const data = (await response.json()) as ThreadListResponse;
const nextThreads = Array.isArray(data.threads) ? data.threads : [];
const nextArchivedThreads = Array.isArray(data.archived_threads) ? data.archived_threads : [];
setThreads(sortThreads(applyFilter(nextThreads)));
setArchivedThreads(sortThreads(applyFilter(nextArchivedThreads)));
} catch (fetchError) {
console.error('获取会话列表失败', fetchError);
setError('会话列表加载失败,请稍后重试');
} finally {
setIsLoading(false);
}
}, [applyFilter, authedFetch]);
useEffect(() => {
if (!isOpen) return;
void refreshThreads();
}, [isOpen, refreshThreads]);
const allThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
const handleCreateThread = async () => {
if (isBusy || isCreating) return;
setIsCreating(true);
setError('');
try {
const response = await authedFetch(THREADS_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '新建技能',
archived: false,
is_real_chat: true,
...createThreadPayload,
}),
});
if (!response.ok) {
throw new Error(`创建会话失败: ${response.status}`);
}
const newThread = (await response.json()) as SkillCreatorThread;
if (!threadFilter || threadFilter(newThread)) {
setThreads((prev) => sortThreads([newThread, ...prev]));
}
onThreadSelect(String(newThread.id));
} catch (createError) {
console.error('创建会话失败', createError);
setError('新建会话失败,请稍后重试');
} finally {
setIsCreating(false);
}
};
const handleDeleteThread = async (thread: SkillCreatorThread) => {
if (isBusy || deletingThreadId === thread.id) return;
const confirmed = window.confirm(`确认删除会话“${thread.title || `会话 ${thread.id}`}”吗?`);
if (!confirmed) return;
setDeletingThreadId(thread.id);
setError('');
try {
const response = await authedFetch(`${THREADS_API_URL}/${thread.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`删除会话失败: ${response.status}`);
}
const nextThreads = threads.filter((item) => item.id !== thread.id);
const nextArchivedThreads = archivedThreads.filter((item) => item.id !== thread.id);
setThreads(nextThreads);
setArchivedThreads(nextArchivedThreads);
if (currentThreadId === String(thread.id)) {
const fallbackThread = [...nextThreads, ...nextArchivedThreads][0];
onThreadSelect(fallbackThread ? String(fallbackThread.id) : '');
}
} catch (deleteError) {
console.error('删除会话失败', deleteError);
setError('删除会话失败,请稍后重试');
} finally {
setDeletingThreadId(null);
}
};
const renderThreadSection = (title: string, items: SkillCreatorThread[], archived = false) => (
<div className="space-y-2.5">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{archived ? <Archive className="h-3.5 w-3.5" /> : <MessageSquareText className="h-3.5 w-3.5" />}
<span className="text-[11px] font-semibold tracking-[0.14em]">{title}</span>
</div>
<span className={`text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{items.length}</span>
</div>
{items.length === 0 ? (
<div
className={`rounded-xl border border-dashed px-3 py-4 text-center text-xs ${
isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
}`}
>
{emptyText}
</div>
) : (
<div className="space-y-1">
{items.map((thread) => {
const isActive = currentThreadId === String(thread.id);
const isDeleting = deletingThreadId === thread.id;
return (
<button
key={thread.id}
type="button"
disabled={isBusy}
onClick={() => onThreadSelect(String(thread.id))}
className={`group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
isActive
? isDarkMode
? 'border-slate-600 bg-slate-800/90 text-slate-50 shadow-sm'
: 'border-red-200 bg-red-50/80 text-gray-900 shadow-sm'
: isDarkMode
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900'
} ${isBusy ? 'cursor-not-allowed opacity-70' : ''}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
isActive
? isDarkMode
? 'bg-slate-700 text-slate-200'
: 'bg-white text-red-500 ring-1 ring-red-100'
: isDarkMode
? 'bg-slate-800 text-slate-400'
: 'bg-gray-100 text-gray-500'
}`}
>
{archived ? <Archive className="h-4 w-4" /> : <MessageSquareText className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{thread.title || `会话 ${thread.id}`}</span>
</span>
<span
onClick={(event) => {
event.stopPropagation();
void handleDeleteThread(thread);
}}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
} ${
isDarkMode
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
}`}
role="button"
aria-label={`删除会话 ${thread.title || thread.id}`}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDeleteThread(thread);
}
}}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</span>
</button>
);
})}
</div>
)}
</div>
);
return (
<aside
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity,transform,border-color] duration-300 ease-out ${
isOpen ? 'w-[286px] translate-x-0 opacity-100' : 'w-0 -translate-x-3 opacity-0 pointer-events-none'
} ${
isDarkMode ? 'border-slate-700 bg-slate-950/90 text-slate-100' : 'border-gray-200 bg-[#fafafa] text-gray-900'
}`}
>
<div className={`border-b px-4 py-4 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-white'}`}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">聊天记录</h3>
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{subtitle}</p>
</div>
<button
type="button"
onClick={onClose}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
title="关闭聊天记录"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
disabled={isBusy || isCreating}
onClick={() => void handleCreateThread()}
className={`inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${
isDarkMode
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
} disabled:cursor-not-allowed disabled:opacity-70`}
>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{createButtonText}
</button>
<button
type="button"
disabled={isLoading}
onClick={() => void refreshThreads()}
className={`flex h-9 w-9 items-center justify-center rounded-lg border transition ${
isDarkMode
? 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
} ${isLoading ? 'cursor-not-allowed opacity-70' : ''}`}
title="刷新会话列表"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{error && (
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${isDarkMode ? 'bg-rose-500/10 text-rose-300' : 'bg-rose-50 text-rose-500'}`}>
{error}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{isLoading && allThreads.length === 0 ? (
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载聊天记录...
</div>
) : (
<div className="space-y-5">
{renderThreadSection('进行中', threads)}
{renderThreadSection('已归档', archivedThreads, true)}
</div>
)}
</div>
</aside>
);
}