代码修改
This commit is contained in:
1 parent
ef1481be63
commit
a4b7ab5e9d
168 files changed
+76575
No files matched your search
@@ -0,0 +1,337 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user