338 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|