From d2bd27096886c7abbcfec4d60f615601864a75d0 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 13 Mar 2026 17:21:38 +0700 Subject: [PATCH] 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. --- ...m-260312-1445-discord-guild-user-memory.md | 136 ----------------- ui/web/src/i18n/locales/en/agents.json | 3 + ui/web/src/i18n/locales/vi/agents.json | 3 + ui/web/src/i18n/locales/zh/agents.json | 3 + ui/web/src/pages/agents/agent-list-row.tsx | 120 +++++++++++++++ ui/web/src/pages/agents/agents-page.tsx | 143 ++++++++++++++---- 6 files changed, 244 insertions(+), 164 deletions(-) delete mode 100644 plans/reports/brainstorm-260312-1445-discord-guild-user-memory.md create mode 100644 ui/web/src/pages/agents/agent-list-row.tsx diff --git a/plans/reports/brainstorm-260312-1445-discord-guild-user-memory.md b/plans/reports/brainstorm-260312-1445-discord-guild-user-memory.md deleted file mode 100644 index fdced417..00000000 --- a/plans/reports/brainstorm-260312-1445-discord-guild-user-memory.md +++ /dev/null @@ -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? diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 6088de0a..dc3709fe 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 4b0c7fa5..4dfafb0f 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -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ồ", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index 625a38e9..1417c0af 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -7,6 +7,9 @@ "emptyDescription": "创建您的第一个Agent以开始使用。", "noMatchTitle": "未找到匹配的Agent", "noMatchDescription": "请尝试其他搜索词。", + "viewCard": "卡片视图", + "viewList": "列表视图", + "allCreators": "所有创建者", "presets": { "foxSpirit": { "label": "🐱 小狐", diff --git a/ui/web/src/pages/agents/agent-list-row.tsx b/ui/web/src/pages/agents/agent-list-row.tsx new file mode 100644 index 00000000..1457a428 --- /dev/null +++ b/ui/web/src/pages/agents/agent-list-row.tsx @@ -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 | null)?.self_evolve); + + return ( + + )} + {onDelete && ( + + )} + + + ); +} diff --git a/ui/web/src/pages/agents/agents-page.tsx b/ui/web/src/pages/agents/agents-page.tsx index 641e3985..24a5e65a 100644 --- a/ui/web/src/pages/agents/agents-page.tsx +++ b/ui/web/src/pages/agents/agents-page.tsx @@ -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(); const [createOpen, setCreateOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(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 (
@@ -73,13 +102,66 @@ export function AgentsPage() { } /> -
+ {/* Toolbar: search + creator filter + view toggle */} +
+ + {/* Creator filter */} + {ownerIDs.length > 0 && ( + + )} + + {/* View toggle */} + +
+ + + + + {t("viewCard")} + + + + + + {t("viewList")} + +
+
@@ -92,9 +174,9 @@ export function AgentsPage() { ) : filtered.length === 0 ? ( -
- {pageItems.map((agent) => ( - { - 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)} - /> - ))} -
+ {viewMode === "card" ? ( +
+ {pageItems.map((agent) => ( + handleClick(agent)} + onResummon={() => handleResummon(agent)} + onDelete={() => setDeleteTarget(agent.id)} + /> + ))} +
+ ) : ( +
+ {pageItems.map((agent) => ( + handleClick(agent)} + onResummon={() => handleResummon(agent)} + onDelete={() => setDeleteTarget(agent.id)} + /> + ))} +
+ )}
{ 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 });