feat(ui): add list view and creator filter to agents page

Add card/list view toggle and owner filter dropdown to the agents page.
New AgentListRow component for compact list display. i18n keys added
for en, vi, zh locales.
This commit is contained in:
viettranx committed 2026-03-13 17:21:38 +07:00
1 parent ddd4565380
commit d2bd270968
6 files changed
+244 -164

No files matched your search

@@ -1,136 +0,0 @@
# Brainstorm: Discord Guild-User Memory Sharing
**Date:** 2026-03-12
**Branch:** feat/discord-guild-user-memory
**Status:** Đề xuất — chờ implement
---
## Vấn đề
GoClaw bot khi được add vào Discord server hiện tại có bộ nhớ tách biệt theo từng user trong cùng server: mỗi khi user chuyển sang channel khác, bot không nhớ tên, cách xưng hô, ngôn ngữ, preferences. User phải tự giới thiệu lại từ đầu.
**Discord khác các platform khác:**
- Telegram: 1 bot = nhiều DM chat riêng biệt, mỗi chat có identity độc lập → không bị vấn đề này
- Discord: 1 server → nhiều channels → cùng 1 danh sách members → user mong muốn bot nhớ họ xuyên suốt các channels
---
## Root Cause (Cập nhật sau phân tích sâu)
Ban đầu nghĩ vấn đề là per-channel scoping. Sau khi đọc `cmd/gateway_consumer.go:97-110`, phát hiện code hiện tại **đã** có Discord-specific guild_id handling:
```go
// Group-scoped UserID — For Discord: use guild_id so all channels
// in the same server share context files, memory, and seeding.
userID := msg.UserID
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
groupID := msg.ChatID
if guildID := msg.Metadata["guild_id"]; guildID != "" {
groupID = guildID // Discord: dùng guild_id thay vì channelID
}
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
```
→ Discord groups hiện đang dùng `userID = "group:discord:{guildID}"` — context files đã được **share across channels trong cùng server**.
**Root cause thực sự:** `"group:discord:{guildID}"` là scope **shared bởi tất cả users** trong server. Không có per-individual separation. User A setup → User B ghi đè USER.md → mất thông tin cá nhân.
**Scope mapping hiện tại:**
| Platform | Group userID | Per-individual? |
|---|---|---|
| Telegram | `group:telegram:{chatID}` | ✗ shared bởi all users |
| Discord | `group:discord:{guildID}` | ✗ shared bởi all users |
| Zalo, Slack... | `group:{channel}:{chatID}` | ✗ shared bởi all users |
**Files liên quan:**
- `cmd/gateway_consumer.go:97-110` — nơi build userID cho group sessions (điểm thay đổi chính)
- `internal/tools/context_file_interceptor.go:250` — permission check dùng `"group:"` prefix
- `internal/store/context.go` — `UserIDKey`, `SenderIDKey` context keys
---
## Yêu cầu đã xác định
- **Core problem:** Phải tự giới thiệu lại, bot không nhớ tên/preferences khi sang channel khác trong cùng server
- **Memory scope:** Per-user trong cùng guild (Server A ≠ Server B, User A ≠ User B)
- **Conversation history:** Giữ riêng từng channel (không share)
- **Profile loading:** Active sender (mỗi message load profile của người đang gửi)
- **DM ↔ Group:** Share cùng profile (Phase 2)
---
## Giải pháp đề xuất
### Phase 1: Guild-User scoped userID (Core Fix)
**Thay đổi duy nhất:** `cmd/gateway_consumer.go` (~3 dòng), chỉ ảnh hưởng Discord:
```go
if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
if guildID := msg.Metadata["guild_id"]; guildID != "" && msg.SenderID != "" {
// Discord: per-guild-user (cá nhân, không shared)
userID = fmt.Sprintf("guild:%s:user:%s", guildID, msg.SenderID)
} else {
groupID := msg.ChatID
userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
}
}
```
Thêm update nhỏ trong `context_file_interceptor.go:250` để handle `"guild:"` prefix song song với `"group:"` trong permission check.
**Behavior mới:**
| Scenario | Session History | Profile (USER.md) |
|---|---|---|
| User A ở #general | `agent:X:discord:group:{channelA}` | `guild:G1:user:A` ← shared |
| User A ở #tech | `agent:X:discord:group:{channelB}` | `guild:G1:user:A` ← same ✓ |
| User B ở #general | `agent:X:discord:group:{channelA}` | `guild:G1:user:B` ← isolated ✓ |
| User A ở Server khác | `agent:X:discord:group:{channelX}` | `guild:G2:user:A` ← isolated ✓ |
**Zero new infrastructure** — tận dụng hoàn toàn `user_context_files` table và `ContextFileInterceptor` hiện có.
**Ảnh hưởng các channel khác: KHÔNG** — Telegram, Zalo, Feishu, Slack không có `guild_id` trong metadata → luồng else → giữ nguyên `"group:{channel}:{chatID}"`.
---
### Phase 2: Session Participants Index (Enhancement)
Mỗi message trong group channel chỉ inject USER.md của active sender. Để bot biết về **tất cả users đã chat** trong session, cần thêm:
- `participants []string` vào session metadata, cập nhật mỗi khi có message từ user mới
- Khi build system context: batch-load USER.mds của tất cả participants → inject dạng:
```
[Known participants in this channel]
- Alice (user:123): Người Việt, prefer tiếng Việt, senior developer
- Bob (user:456): English speaker, prefer concise answers
```
**Tại sao cần Phase 2 riêng:** `providers.Message` không có `SenderID` field → không thể extract users từ session history. Cần participants metadata riêng.
**Context overhead:** ~10 users × 300 tokens = 3000 tokens (negligible với 200k context window)
---
## Security Analysis
| Risk | Mitigation |
|---|---|
| User X đọc profile của User Y | Impossible: key include `senderID` |
| Server A leak sang Server B | `guild:{guildID}` prefix đảm bảo isolation |
| Unauthorized writes to SOUL.md | `protectedFileSet` check vẫn giữ — thêm `"guild:"` prefix vào điều kiện |
| Agent A leak sang Agent B | `agent_id` vẫn là primary key trong `user_context_files` |
---
## Unresolved Questions
1. **Backward compatibility:** Users hiện tại đã setup trong Discord groups → data cũ lưu theo `"group:discord:{guildID}"` (shared). Sau khi đổi sang per-user scope, data cũ sẽ bị "orphan". Cần migration script hoặc fallback lookup?
2. **DM ↔ Group bridging (Phase 2):** Discord DMs không có `guildID` → userID vẫn là `senderID` cho DMs. Hai scope khác nhau (DM vs group) nhưng user mong muốn nhất quán. Approach: lookup guild profile khi DM?
3. **Multi-agent Discord:** Cùng 1 Discord server có 2 agents → per-agent isolation via `agent_id` → user phải giới thiệu lại với mỗi agent. Acceptable?
+3
View File
@@ -7,6 +7,9 @@
"emptyDescription": "Create your first agent to get started.",
"noMatchTitle": "No matching agents",
"noMatchDescription": "Try a different search term.",
"viewCard": "Card view",
"viewList": "List view",
"allCreators": "All creators",
"presets": {
"foxSpirit": {
"label": "🐱 Fox Spirit",
+3
View File
@@ -7,6 +7,9 @@
"emptyDescription": "Tạo agent đầu tiên để bắt đầu.",
"noMatchTitle": "Không tìm thấy agent phù hợp",
"noMatchDescription": "Thử từ khóa tìm kiếm khác.",
"viewCard": "Dạng thẻ",
"viewList": "Dạng danh sách",
"allCreators": "Tất cả người tạo",
"presets": {
"foxSpirit": {
"label": "🐱 Tiểu Hồ",
+3
View File
@@ -7,6 +7,9 @@
"emptyDescription": "创建您的第一个Agent以开始使用。",
"noMatchTitle": "未找到匹配的Agent",
"noMatchDescription": "请尝试其他搜索词。",
"viewCard": "卡片视图",
"viewList": "列表视图",
"allCreators": "所有创建者",
"presets": {
"foxSpirit": {
"label": "🐱 小狐",
+120
View File
@@ -0,0 +1,120 @@
import { Bot, Star, Trash2, RotateCcw, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { AgentData } from "@/types/agent";
interface AgentListRowProps {
agent: AgentData;
ownerName?: string;
onClick: () => void;
onResummon?: () => void;
onDelete?: () => void;
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function AgentListRow({ agent, ownerName, onClick, onResummon, onDelete }: AgentListRowProps) {
const { t } = useTranslation("agents");
const displayName = agent.display_name
|| (UUID_RE.test(agent.agent_key) ? t("card.unnamedAgent") : agent.agent_key);
const selfEvolve = agent.agent_type === "predefined" && Boolean((agent.other_config as Record<string, unknown> | null)?.self_evolve);
return (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm"
>
{/* Icon */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Bot className="h-4 w-4" />
</div>
{/* Name + key */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-semibold">{displayName}</span>
{agent.is_default && <Star className="h-3 w-3 shrink-0 fill-amber-400 text-amber-400" />}
</div>
{agent.display_name && !UUID_RE.test(agent.agent_key) && (
<div className="truncate text-xs text-muted-foreground">{agent.agent_key}</div>
)}
</div>
{/* Status */}
<div className="hidden shrink-0 sm:block">
{agent.status === "summoning" ? (
<Badge variant="outline" className="animate-pulse border-violet-400 text-violet-600 dark:text-violet-400">
{t("card.summoning")}
</Badge>
) : agent.status === "summon_failed" ? (
<Badge variant="destructive">{t("card.summonFailed")}</Badge>
) : (
<Badge variant={agent.status === "active" ? "success" : "secondary"}>{agent.status}</Badge>
)}
</div>
{/* Model */}
<div className="hidden shrink-0 text-xs text-muted-foreground md:block md:w-40 md:truncate">
{[agent.provider, agent.model].filter(Boolean).join(" / ")}
</div>
{/* Type + evolve */}
<div className="hidden shrink-0 items-center gap-1 lg:flex">
<Badge variant="outline" className="text-[11px]">{agent.agent_type}</Badge>
{selfEvolve && (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="bg-violet-100 text-[11px] text-violet-700 hover:bg-violet-100 dark:bg-violet-900/30 dark:text-violet-300">
<Sparkles className="mr-0.5 h-3 w-3" />
{t("card.evolving")}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{t("card.evolvingTooltip")}
</TooltipContent>
</Tooltip>
)}
</div>
{/* Owner */}
{ownerName && (
<div className="hidden shrink-0 text-xs text-muted-foreground xl:block xl:w-28 xl:truncate">
{ownerName}
</div>
)}
{/* Context window */}
{agent.context_window > 0 && (
<span className="hidden shrink-0 text-[11px] text-muted-foreground lg:block">
{(agent.context_window / 1000).toFixed(0)}K
</span>
)}
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{agent.status === "summon_failed" && onResummon && (
<Button
variant="outline"
size="xs"
onClick={(e) => { e.stopPropagation(); onResummon(); }}
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</button>
);
}
+115 -28
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router";
import { Plus, Bot } from "lucide-react";
import { Plus, Bot, LayoutGrid, List } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
@@ -9,10 +9,19 @@ import { Pagination } from "@/components/shared/pagination";
import { CardSkeleton } from "@/components/shared/loading-skeleton";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useAgents } from "./hooks/use-agents";
import { AgentCard } from "./agent-card";
import { AgentListRow } from "./agent-list-row";
import { AgentCreateDialog } from "./agent-create-dialog";
import { AgentDetailPage } from "./agent-detail/agent-detail-page";
import { SummoningModal } from "./summoning-modal";
@@ -26,10 +35,16 @@ export function AgentsPage() {
const showSkeleton = useDeferredLoading(loading && agents.length === 0);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"card" | "list">("card");
const [ownerFilter, setOwnerFilter] = useState<string | undefined>();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [summoningAgent, setSummoningAgent] = useState<{ id: string; name: string } | null>(null);
// Collect unique owner IDs for filter + contact resolution
const ownerIDs = useMemo(() => [...new Set(agents.map((a) => a.owner_id).filter(Boolean))], [agents]);
const { resolve } = useContactResolver(ownerIDs);
const handleResummon = async (agent: { id: string; display_name?: string; agent_key: string }) => {
try {
await resummonAgent(agent.id);
@@ -50,6 +65,7 @@ export function AgentsPage() {
}
const filtered = agents.filter((a) => {
if (ownerFilter && a.owner_id !== ownerFilter) return false;
const q = search.toLowerCase();
return (
a.agent_key.toLowerCase().includes(q) ||
@@ -59,7 +75,20 @@ export function AgentsPage() {
const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered);
useEffect(() => { resetPage(); }, [search, resetPage]);
useEffect(() => { resetPage(); }, [search, ownerFilter, resetPage]);
const resolveOwnerName = (id: string) => {
const contact = resolve(id);
return contact?.display_name || contact?.username || id;
};
const handleClick = (agent: { id: string; display_name?: string; agent_key: string; status: string }) => {
if (agent.status === "summoning") {
setSummoningAgent({ id: agent.id, name: agent.display_name || agent.agent_key });
} else {
navigate(`/agents/${agent.id}`);
}
};
return (
<div className="p-4 sm:p-6">
@@ -73,13 +102,66 @@ export function AgentsPage() {
}
/>
<div className="mt-4">
{/* Toolbar: search + creator filter + view toggle */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t("searchPlaceholder")}
className="max-w-sm"
/>
{/* Creator filter */}
{ownerIDs.length > 0 && (
<Select
value={ownerFilter ?? "__all__"}
onValueChange={(v) => setOwnerFilter(v === "__all__" ? undefined : v)}
>
<SelectTrigger className="h-9 w-44 text-xs">
<SelectValue placeholder={t("allCreators")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("allCreators")}</SelectItem>
{ownerIDs.map((id) => (
<SelectItem key={id} value={id}>
{resolveOwnerName(id)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* View toggle */}
<TooltipProvider>
<div className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "card" ? "default" : "ghost"}
size="xs"
className="h-7 w-7 p-0"
onClick={() => setViewMode("card")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("viewCard")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "list" ? "default" : "ghost"}
size="xs"
className="h-7 w-7 p-0"
onClick={() => setViewMode("list")}
>
<List className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("viewList")}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div className="mt-6">
@@ -92,9 +174,9 @@ export function AgentsPage() {
) : filtered.length === 0 ? (
<EmptyState
icon={Bot}
title={search ? t("noMatchTitle") : t("emptyTitle")}
title={search || ownerFilter ? t("noMatchTitle") : t("emptyTitle")}
description={
search
search || ownerFilter
? t("noMatchDescription")
: t("emptyDescription")
}
@@ -102,26 +184,32 @@ export function AgentsPage() {
) : (
<>
<TooltipProvider>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pageItems.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onClick={() => {
if (agent.status === "summoning") {
setSummoningAgent({
id: agent.id,
name: agent.display_name || agent.agent_key,
});
} else {
navigate(`/agents/${agent.id}`);
}
}}
onResummon={() => handleResummon(agent)}
onDelete={() => setDeleteTarget(agent.id)}
/>
))}
</div>
{viewMode === "card" ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{pageItems.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onClick={() => handleClick(agent)}
onResummon={() => handleResummon(agent)}
onDelete={() => setDeleteTarget(agent.id)}
/>
))}
</div>
) : (
<div className="flex flex-col gap-2">
{pageItems.map((agent) => (
<AgentListRow
key={agent.id}
agent={agent}
ownerName={resolveOwnerName(agent.owner_id)}
onClick={() => handleClick(agent)}
onResummon={() => handleResummon(agent)}
onDelete={() => setDeleteTarget(agent.id)}
/>
))}
</div>
)}
</TooltipProvider>
<div className="mt-4">
<Pagination
@@ -143,7 +231,6 @@ export function AgentsPage() {
onCreate={async (data) => {
const created = await createAgent(data);
refresh();
// Auto-show summoning modal if agent is being summoned
if (created && typeof created === "object" && "status" in created && created.status === "summoning") {
const ag = created as { id: string; display_name?: string; agent_key: string };
setSummoningAgent({ id: ag.id, name: ag.display_name || ag.agent_key });