feat: 支持按 Agent 筛选对话

This commit is contained in:
ji ruo committed 2026-09-10 15:10:47 +08:00
1 parent 52e0b0dbb4
commit 076146fa81
1 file changed
+72 -11
+72 -11
View File
@@ -46,7 +46,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode"; import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
@@ -908,6 +908,7 @@ export default function ChatPage() {
const [agents, setAgents] = React.useState<Agent[]>([]); const [agents, setAgents] = React.useState<Agent[]>([]);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]); const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [convs, setConvs] = React.useState<Conversation[]>([]); const [convs, setConvs] = React.useState<Conversation[]>([]);
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
const [selectedId, setSelectedId] = React.useState<number | null>(null); const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [renamingId, setRenamingId] = React.useState<number | null>(null); const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState(""); const [renameText, setRenameText] = React.useState("");
@@ -978,17 +979,44 @@ export default function ChatPage() {
const selected = React.useMemo(() => convs.find((c) => c.id === selectedId) ?? null, [convs, selectedId]); const selected = React.useMemo(() => convs.find((c) => c.id === selectedId) ?? null, [convs, selectedId]);
const agentByKey = React.useMemo(() => new Map(agents.map((agent) => [agent.key, agent])), [agents]); const agentByKey = React.useMemo(() => new Map(agents.map((agent) => [agent.key, agent])), [agents]);
const filteredConversations = React.useMemo(
() => (agentFilter === null ? convs : convs.filter((conversation) => conversation.agent_key === agentFilter)),
[convs, agentFilter],
);
const visibleConversations = React.useMemo( const visibleConversations = React.useMemo(
() => convs.slice(0, visibleConversationCount), () => filteredConversations.slice(0, visibleConversationCount),
[convs, visibleConversationCount], [filteredConversations, visibleConversationCount],
); );
// conversation agents: custom agents + conversational built-ins (role=assistant, // conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker) // e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
// are task-specific and stay hidden from the chat page. // are task-specific and stay hidden from the chat page.
const chatAgents = React.useMemo(() => agents.filter((a) => !a.builtin || a.role === "assistant"), [agents]); const chatAgents = React.useMemo(() => agents.filter((a) => !a.builtin || a.role === "assistant"), [agents]);
const agentFilterOptions = React.useMemo(() => {
const counts = new Map<string, number>();
for (const conversation of convs) {
counts.set(conversation.agent_key, (counts.get(conversation.agent_key) ?? 0) + 1);
}
// Include historical sources even if their Agent has since been disabled or deleted.
const keys = new Set([...chatAgents.map((agent) => agent.key), ...counts.keys()]);
if (agentFilter !== null) keys.add(agentFilter);
return [...keys]
.map((key) => ({ key, name: agentByKey.get(key)?.name || key, count: counts.get(key) ?? 0 }))
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
}, [convs, chatAgents, agentByKey, agentFilter]);
const conversationCountLabel =
agentFilter === null ? `共 ${convs.length} 个` : `${filteredConversations.length} / ${convs.length} 个`;
function changeAgentFilter(key: string | null) {
setAgentFilter(key);
setVisibleConversationCount(CONVERSATION_LIST_PAGE);
setSelectedConversationIds(new Set());
setBulkDeleteOpen(false);
setRenamingId(null);
}
const selectedConversationCount = selectedConversationIds.size; const selectedConversationCount = selectedConversationIds.size;
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length; const allConversationsSelected =
filteredConversations.length > 0 && selectedConversationCount === filteredConversations.length;
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected; const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
let conversationHeaderChecked: boolean | "indeterminate" = false; let conversationHeaderChecked: boolean | "indeterminate" = false;
if (allConversationsSelected) conversationHeaderChecked = true; if (allConversationsSelected) conversationHeaderChecked = true;
@@ -1004,7 +1032,9 @@ export default function ChatPage() {
}, []); }, []);
function toggleAllConversations(checked: boolean) { function toggleAllConversations(checked: boolean) {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set()); setSelectedConversationIds(
checked ? new Set(filteredConversations.map((conversation) => conversation.id)) : new Set(),
);
} }
function exitSelectionMode() { function exitSelectionMode() {
@@ -1117,16 +1147,37 @@ export default function ChatPage() {
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}> <Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话 <PlusIcon /> 新建对话
</Button> </Button>
<Select
value={agentFilter === null ? "all" : `agent:${agentFilter}`}
onValueChange={(value) => changeAgentFilter(value === "all" ? null : value.slice(6))}
disabled={bulkDeleting}
>
<SelectTrigger size="sm" className="w-full min-w-0" aria-label="按 Agent 筛选对话">
<Bot />
<SelectValue placeholder="全部 Agent" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部 Agent</SelectItem>
{agentFilterOptions.map((agent) => (
<SelectItem key={agent.key} value={`agent:${agent.key}`}>
{agent.name}({agent.count})
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{convs.length > 0 && {convs.length > 0 &&
(selectionMode ? ( (selectionMode ? (
<div className="flex items-center gap-2 px-1"> <div className="flex items-center gap-2 px-1">
<Checkbox <Checkbox
checked={conversationHeaderChecked} checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)} onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话" aria-label="选择当前筛选的全部对话"
disabled={filteredConversations.length === 0 || bulkDeleting}
/> />
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums"> <span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`} {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : conversationCountLabel}
</span> </span>
{selectedConversationCount > 0 && ( {selectedConversationCount > 0 && (
<Button <Button
@@ -1146,13 +1197,14 @@ export default function ChatPage() {
) : ( ) : (
<div className="flex items-center gap-2 px-1"> <div className="flex items-center gap-2 px-1">
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums"> <span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
共 {convs.length} 个 {conversationCountLabel}
</span> </span>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-muted-foreground" className="text-muted-foreground"
onClick={() => setSelectionMode(true)} onClick={() => setSelectionMode(true)}
disabled={filteredConversations.length === 0}
> >
<ListChecksIcon data-icon="inline-start" /> <ListChecksIcon data-icon="inline-start" />
多选 多选
@@ -1160,9 +1212,17 @@ export default function ChatPage() {
</div> </div>
))} ))}
</div> </div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"> <ScrollArea
key={agentFilter === null ? "all" : `agent:${agentFilter}`}
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="flex min-w-0 flex-col gap-0.5 p-2"> <div className="flex min-w-0 flex-col gap-0.5 p-2">
{convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>} {filteredConversations.length === 0 && (
<p className="text-muted-foreground px-2 py-6 text-center text-xs">
{agentFilter === null ? "暂无对话" : "该 Agent 暂无对话"}
</p>
)}
{visibleConversations.map((c) => ( {visibleConversations.map((c) => (
<ConversationItem <ConversationItem
key={c.id} key={c.id}
@@ -1183,7 +1243,7 @@ export default function ChatPage() {
onSelectedForDeleteChange={toggleConversationSelected} onSelectedForDeleteChange={toggleConversationSelected}
/> />
))} ))}
{visibleConversationCount < convs.length && ( {visibleConversationCount < filteredConversations.length && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1215,6 +1275,7 @@ export default function ChatPage() {
agents={chatAgents} agents={chatAgents}
profiles={profiles} profiles={profiles}
onStarted={(c, pending) => { onStarted={(c, pending) => {
if (agentFilter !== null && agentFilter !== c.agent_key) changeAgentFilter(null);
// Insert the new conversation immediately so `selected` resolves to // Insert the new conversation immediately so `selected` resolves to
// it on this render (switching to ChatView right away, before the // it on this render (switching to ChatView right away, before the
// async reloadConvs lands); reloadConvs then reconciles titles etc. // async reloadConvs lands); reloadConvs then reconciles titles etc.