334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import { useState } from "react";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { cn } from "@/shared/ui/cn";
|
|
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
|
import type { AgentChatSessionSummary } from "../api/client";
|
|
import {
|
|
agentLayoutTransition,
|
|
agentPanelItemVariants,
|
|
agentPanelListVariants
|
|
} from "./agent-motion";
|
|
|
|
type AgentHistoryPanelProps = {
|
|
activeSessionId?: string | null;
|
|
loading: boolean;
|
|
loadingSessionId: string | null;
|
|
sessions: AgentChatSessionSummary[];
|
|
onRefresh?: () => Promise<void> | void;
|
|
onSelectSession: (sessionId: string) => void;
|
|
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
|
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
|
};
|
|
|
|
export function AgentHistoryPanel({
|
|
activeSessionId,
|
|
loading,
|
|
loadingSessionId,
|
|
sessions,
|
|
onRefresh,
|
|
onSelectSession,
|
|
onRenameSession,
|
|
onDeleteSession
|
|
}: AgentHistoryPanelProps) {
|
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
|
const [draftTitle, setDraftTitle] = useState("");
|
|
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
|
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
|
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
|
|
|
const beginRename = (session: AgentChatSessionSummary) => {
|
|
setEditingSessionId(session.id);
|
|
setDraftTitle(session.title);
|
|
};
|
|
|
|
const cancelRename = () => {
|
|
setEditingSessionId(null);
|
|
setDraftTitle("");
|
|
};
|
|
|
|
const saveRename = (session: AgentChatSessionSummary) => {
|
|
const normalizedTitle = draftTitle.trim();
|
|
if (!normalizedTitle || normalizedTitle === session.title) {
|
|
cancelRename();
|
|
return;
|
|
}
|
|
|
|
setSavingSessionId(session.id);
|
|
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
|
.then(cancelRename)
|
|
.finally(() => setSavingSessionId(null));
|
|
};
|
|
|
|
const confirmDelete = (sessionId: string) => {
|
|
setDeletingSessionId(sessionId);
|
|
void Promise.resolve(onDeleteSession?.(sessionId))
|
|
.then(() => setConfirmingDeleteSessionId(null))
|
|
.finally(() => setDeletingSessionId(null));
|
|
};
|
|
|
|
return (
|
|
<div className="px-3 pb-3">
|
|
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
|
<p className="mt-0.5 text-xs text-slate-500">
|
|
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
aria-label="刷新 Agent 历史记录"
|
|
title="刷新 Agent 历史记录"
|
|
size="icon"
|
|
variant="ghost"
|
|
disabled={loading && sessions.length === 0}
|
|
loading={loading && sessions.length > 0}
|
|
loadingLabel="正在刷新 Agent 历史记录"
|
|
onClick={() => void Promise.resolve(onRefresh?.())}
|
|
>
|
|
<RefreshCw size={15} aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
|
|
<motion.div
|
|
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
|
variants={agentPanelListVariants}
|
|
animate="animate"
|
|
>
|
|
<AnimatePresence initial={false} mode="popLayout">
|
|
{loading && sessions.length === 0 ? (
|
|
<motion.div
|
|
key="history-loading"
|
|
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-slate-500"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
>
|
|
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
|
正在加载
|
|
</motion.div>
|
|
) : sessions.length > 0 ? (
|
|
sessions.map((session) => {
|
|
const active = session.id === activeSessionId;
|
|
const itemLoading = loadingSessionId === session.id;
|
|
const editing = editingSessionId === session.id;
|
|
const saving = savingSessionId === session.id;
|
|
const deleting = deletingSessionId === session.id;
|
|
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
|
|
|
return (
|
|
<motion.div
|
|
key={session.id}
|
|
layout
|
|
className={cn(
|
|
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
|
active && !editing
|
|
? "agent-history-item-active border border-blue-200"
|
|
: "border border-transparent"
|
|
)}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
transition={agentLayoutTransition}
|
|
>
|
|
<AnimatePresence initial={false} mode="popLayout">
|
|
{confirmingDelete ? (
|
|
<motion.div
|
|
key="confirm-delete"
|
|
className="min-w-0"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
>
|
|
<span
|
|
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
|
title={session.title}
|
|
>
|
|
删除“{session.title}”?
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
|
删除后无法从历史记录中恢复
|
|
</span>
|
|
</motion.div>
|
|
) : editing ? (
|
|
<motion.form
|
|
key="rename"
|
|
className="min-w-0"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
saveRename(session);
|
|
}}
|
|
>
|
|
<Input
|
|
autoFocus
|
|
className="agent-history-rename-input h-8 rounded-lg bg-white text-xs"
|
|
value={draftTitle}
|
|
disabled={saving}
|
|
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Escape") {
|
|
cancelRename();
|
|
}
|
|
}}
|
|
/>
|
|
</motion.form>
|
|
) : (
|
|
<motion.button
|
|
key="session-summary"
|
|
type="button"
|
|
className="agent-history-session-trigger min-w-0 text-left"
|
|
disabled={itemLoading || deleting}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
onClick={() => onSelectSession(session.id)}
|
|
>
|
|
<span
|
|
className="block truncate text-xs font-semibold text-slate-900"
|
|
title={session.title}
|
|
>
|
|
{session.title}
|
|
</span>
|
|
<span className="mt-0.5 block truncate text-xs text-slate-500">
|
|
{formatSessionUpdatedAt(session.updatedAt)}
|
|
</span>
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
<span className="flex items-center gap-2">
|
|
{session.runStatus === "running" || session.isStreaming ? (
|
|
<StatusDot tone="info" activity="live" />
|
|
) : null}
|
|
{itemLoading || saving || deleting ? (
|
|
<Loader2
|
|
size={13}
|
|
className="animate-spin text-blue-600"
|
|
aria-hidden="true"
|
|
/>
|
|
) : null}
|
|
{active && !editing && !confirmingDelete ? (
|
|
<StatusBadge tone="info">当前</StatusBadge>
|
|
) : null}
|
|
{confirmingDelete ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
loading={deleting}
|
|
loadingLabel="正在删除对话"
|
|
onClick={() => confirmDelete(session.id)}
|
|
>
|
|
确认
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
aria-label="取消删除"
|
|
title="取消删除"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
disabled={deleting}
|
|
onClick={() => setConfirmingDeleteSessionId(null)}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</Button>
|
|
</>
|
|
) : editing ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
aria-label="保存对话主题"
|
|
title="保存对话主题"
|
|
size="icon-sm"
|
|
loading={saving}
|
|
loadingLabel="正在保存对话主题"
|
|
onClick={() => saveRename(session)}
|
|
>
|
|
<Check size={14} aria-hidden="true" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
aria-label="取消重命名"
|
|
title="取消重命名"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
disabled={saving}
|
|
onClick={cancelRename}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
aria-label="重命名对话"
|
|
title="重命名对话"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
disabled={itemLoading || deleting || !onRenameSession}
|
|
onClick={() => beginRename(session)}
|
|
>
|
|
<Pencil size={14} aria-hidden="true" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
aria-label="删除对话"
|
|
title="删除对话"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="text-slate-500 hover:bg-red-50 hover:text-red-600"
|
|
disabled={itemLoading || deleting || !onDeleteSession}
|
|
onClick={() => {
|
|
cancelRename();
|
|
setConfirmingDeleteSessionId(session.id);
|
|
}}
|
|
>
|
|
<Trash2 size={14} aria-hidden="true" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</span>
|
|
</motion.div>
|
|
);
|
|
})
|
|
) : (
|
|
<motion.div
|
|
key="history-empty"
|
|
className="agent-history-state rounded-xl px-3 py-3 text-xs text-slate-500"
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
variants={agentPanelItemVariants}
|
|
>
|
|
暂无历史记录
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatSessionUpdatedAt(value: number) {
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
}).format(value);
|
|
}
|