Merge feat/chat-session-grouping: 会话列表按 Agent 分组
This commit is contained in:
commit
066681a71d
1 file changed
+161
-5
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
@@ -52,6 +53,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { mergeActivities } from "@/lib/activity-merge";
|
import { mergeActivities } from "@/lib/activity-merge";
|
||||||
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";
|
||||||
|
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -85,10 +87,46 @@ function fmtDuration(ms: number): string {
|
|||||||
const HISTORY_PAGE = 200;
|
const HISTORY_PAGE = 200;
|
||||||
const CONVERSATION_LIST_PAGE = 100;
|
const CONVERSATION_LIST_PAGE = 100;
|
||||||
|
|
||||||
|
// Which agent groups the user has collapsed in the left rail. Persisted so the
|
||||||
|
// rail looks the same after a reload; unknown keys are harmless (a deleted agent
|
||||||
|
// simply never renders a group again).
|
||||||
|
const COLLAPSED_AGENTS_KEY = "artex.chat.collapsed-agents";
|
||||||
|
|
||||||
function conversationIsPinned(conversation: Conversation): boolean {
|
function conversationIsPinned(conversation: Conversation): boolean {
|
||||||
return conversation.pinned ?? Boolean(conversation.pinned_at);
|
return conversation.pinned ?? Boolean(conversation.pinned_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentGroup is one collapsible section of the left rail: all unpinned
|
||||||
|
// conversations of a single agent, newest activity first.
|
||||||
|
interface AgentGroup {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
conversations: Conversation[];
|
||||||
|
runningCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupByAgent buckets conversations by agent, preserving the incoming order
|
||||||
|
// both inside a group and across groups. The server already sorts by updated_at
|
||||||
|
// DESC, so first-appearance order == most-recently-active group first.
|
||||||
|
function groupByAgent(conversations: Conversation[], agentByKey: Map<string, Agent>): AgentGroup[] {
|
||||||
|
const groups = new Map<string, AgentGroup>();
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
let group = groups.get(conversation.agent_key);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
key: conversation.agent_key,
|
||||||
|
name: agentByKey.get(conversation.agent_key)?.name || conversation.agent_key,
|
||||||
|
conversations: [],
|
||||||
|
runningCount: 0,
|
||||||
|
};
|
||||||
|
groups.set(conversation.agent_key, group);
|
||||||
|
}
|
||||||
|
group.conversations.push(conversation);
|
||||||
|
if (conversation.running) group.runningCount++;
|
||||||
|
}
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
|
|
||||||
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
||||||
// console — shown while a turn is streaming.
|
// console — shown while a turn is streaming.
|
||||||
function LiveBadge() {
|
function LiveBadge() {
|
||||||
@@ -761,10 +799,12 @@ function ChatView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConversationItem is one row in the left rail: title, agent subtitle, inline
|
// ConversationItem is one row in the left rail: title, agent subtitle, inline
|
||||||
// rename, pin marker, and a compact action menu.
|
// rename, pin marker, and a compact action menu. Rows nested under an agent
|
||||||
|
// group drop the agent subtitle (showAgent=false) — the header already says it.
|
||||||
const ConversationItem = React.memo(function ConversationItem({
|
const ConversationItem = React.memo(function ConversationItem({
|
||||||
conv,
|
conv,
|
||||||
agent,
|
agent,
|
||||||
|
showAgent = true,
|
||||||
active,
|
active,
|
||||||
renaming,
|
renaming,
|
||||||
renameText,
|
renameText,
|
||||||
@@ -781,6 +821,7 @@ const ConversationItem = React.memo(function ConversationItem({
|
|||||||
}: {
|
}: {
|
||||||
conv: Conversation;
|
conv: Conversation;
|
||||||
agent?: Agent;
|
agent?: Agent;
|
||||||
|
showAgent?: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
renameText: string;
|
renameText: string;
|
||||||
@@ -859,9 +900,13 @@ const ConversationItem = React.memo(function ConversationItem({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
|
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
|
||||||
<Bot className="size-3 shrink-0" />
|
{showAgent && (
|
||||||
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
|
<>
|
||||||
<span className="shrink-0">·</span>
|
<Bot className="size-3 shrink-0" />
|
||||||
|
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
|
||||||
|
<span className="shrink-0">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
|
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
@@ -921,6 +966,44 @@ const ConversationItem = React.memo(function ConversationItem({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AgentGroupHeader is the sticky, clickable divider above one agent's rows:
|
||||||
|
// collapse chevron, agent name, a running marker, and the row count.
|
||||||
|
function AgentGroupHeader({
|
||||||
|
group,
|
||||||
|
collapsed,
|
||||||
|
hasActive,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
group: AgentGroup;
|
||||||
|
collapsed: boolean;
|
||||||
|
hasActive: boolean;
|
||||||
|
onToggle: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(group.key)}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
title={collapsed ? `展开「${group.name}」` : `收起「${group.name}」`}
|
||||||
|
className={cn(
|
||||||
|
"sticky top-0 z-10 flex min-w-0 items-center gap-1.5 rounded-md bg-card px-1.5 py-1 text-left font-medium text-[11px] transition-colors hover:bg-accent/50",
|
||||||
|
collapsed && hasActive ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className={cn("size-3 shrink-0 transition-transform", !collapsed && "rotate-90")} />
|
||||||
|
<Bot className="size-3 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{group.name}</span>
|
||||||
|
{collapsed && hasActive && (
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-primary" title="当前对话在此分组内" />
|
||||||
|
)}
|
||||||
|
{group.runningCount > 0 && (
|
||||||
|
<Spinner className="size-3 shrink-0" aria-label={`${group.runningCount} 个对话运行中`} />
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 tabular-nums opacity-60">{group.conversations.length}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
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[]>([]);
|
||||||
@@ -937,6 +1020,9 @@ export default function ChatPage() {
|
|||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||||
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
||||||
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
|
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
|
||||||
|
// Collapsed agent groups. Hydrated from localStorage after mount (not a lazy
|
||||||
|
// useState init) so the server and client render the same first pass.
|
||||||
|
const [collapsedAgents, setCollapsedAgents] = React.useState<Set<string>>(() => new Set());
|
||||||
// Pending text + uploaded attachments handed off from a draft that created a
|
// Pending text + uploaded attachments handed off from a draft that created a
|
||||||
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
||||||
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
||||||
@@ -995,6 +1081,27 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
}, [convs]);
|
}, [convs]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const raw = getLocalStorageValue(COLLAPSED_AGENTS_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
try {
|
||||||
|
const keys = JSON.parse(raw);
|
||||||
|
if (Array.isArray(keys))
|
||||||
|
setCollapsedAgents(new Set(keys.filter((key): key is string => typeof key === "string")));
|
||||||
|
} catch {
|
||||||
|
// Corrupted entry → start with everything expanded.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAgentCollapsed = React.useCallback((key: string) => {
|
||||||
|
setCollapsedAgents((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (!next.delete(key)) next.add(key);
|
||||||
|
setLocalStorageValue(COLLAPSED_AGENTS_KEY, JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
||||||
// returns to the same thread instead of the empty draft view. Runs after
|
// returns to the same thread instead of the empty draft view. Runs after
|
||||||
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
||||||
@@ -1024,6 +1131,20 @@ export default function ChatPage() {
|
|||||||
() => filteredConversations.slice(0, visibleConversationCount),
|
() => filteredConversations.slice(0, visibleConversationCount),
|
||||||
[filteredConversations, visibleConversationCount],
|
[filteredConversations, visibleConversationCount],
|
||||||
);
|
);
|
||||||
|
// Pinned rows stay a flat block above the groups (server order = pinned_at DESC);
|
||||||
|
// everything else is bucketed per agent, most-recently-active agent first.
|
||||||
|
const pinnedConversations = React.useMemo(
|
||||||
|
() => visibleConversations.filter(conversationIsPinned),
|
||||||
|
[visibleConversations],
|
||||||
|
);
|
||||||
|
const agentGroups = React.useMemo(
|
||||||
|
() =>
|
||||||
|
groupByAgent(
|
||||||
|
visibleConversations.filter((c) => !conversationIsPinned(c)),
|
||||||
|
agentByKey,
|
||||||
|
),
|
||||||
|
[visibleConversations, agentByKey],
|
||||||
|
);
|
||||||
// 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.
|
||||||
@@ -1260,7 +1381,7 @@ export default function ChatPage() {
|
|||||||
{agentFilter === null ? "暂无对话" : "该 Agent 暂无对话"}
|
{agentFilter === null ? "暂无对话" : "该 Agent 暂无对话"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{visibleConversations.map((c) => (
|
{pinnedConversations.map((c) => (
|
||||||
<ConversationItem
|
<ConversationItem
|
||||||
key={c.id}
|
key={c.id}
|
||||||
conv={c}
|
conv={c}
|
||||||
@@ -1280,6 +1401,41 @@ export default function ChatPage() {
|
|||||||
onSelectedForDeleteChange={toggleConversationSelected}
|
onSelectedForDeleteChange={toggleConversationSelected}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{agentGroups.map((group) => {
|
||||||
|
const collapsed = collapsedAgents.has(group.key);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={group.key}>
|
||||||
|
<AgentGroupHeader
|
||||||
|
group={group}
|
||||||
|
collapsed={collapsed}
|
||||||
|
hasActive={group.conversations.some((c) => c.id === selectedId)}
|
||||||
|
onToggle={toggleAgentCollapsed}
|
||||||
|
/>
|
||||||
|
{!collapsed &&
|
||||||
|
group.conversations.map((c) => (
|
||||||
|
<ConversationItem
|
||||||
|
key={c.id}
|
||||||
|
conv={c}
|
||||||
|
agent={agentByKey.get(c.agent_key)}
|
||||||
|
showAgent={false}
|
||||||
|
active={selectedId === c.id}
|
||||||
|
renaming={renamingId === c.id}
|
||||||
|
renameText={renamingId === c.id ? renameText : ""}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
onStartRename={startRename}
|
||||||
|
onRenameText={setRenameText}
|
||||||
|
onCommitRename={commitRename}
|
||||||
|
onCancelRename={cancelRename}
|
||||||
|
onTogglePinned={togglePinned}
|
||||||
|
onDelete={del}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selectedForDelete={selectedConversationIds.has(c.id)}
|
||||||
|
onSelectedForDeleteChange={toggleConversationSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{visibleConversationCount < filteredConversations.length && (
|
{visibleConversationCount < filteredConversations.length && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in new issue
Block a user