From c8dc9917fee640f6a975923d856183a8ed910be4 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 10 Mar 2026 14:29:01 +0700 Subject: [PATCH] feat(contacts): channel contacts table, auto-collector, contacts page & managers tab redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add channel_contacts migration (000014) with UNIQUE(channel_type, sender_id) - Add ContactStore interface with UPSERT, list, count, merge operations - Add ContactCollector with 30-min TTL cache to skip redundant DB writes - Wire auto-collection into gateway consumer on every inbound message - Add GET /v1/contacts API with pagination, search, channel_type & peer_kind filters - Rename Writers tab → Managers tab (UI-only; backend routes unchanged) - Extract InlineAddForm with scoped state, debounce cleanup, aria-expanded - Add Combobox contact picker with debounced search + auto-fill - Add Contacts page with server-side pagination, filters, i18n (en/vi/zh) - Add shared ChannelContact type, sidebar nav entry, route & query keys - Fix ILIKE wildcard escape, log CountContacts errors, extract shared type --- cmd/gateway_consumer.go | 20 +- cmd/gateway_http_handlers.go | 2 +- internal/http/channel_instances.go | 70 ++- internal/store/contact_collector.go | 36 ++ internal/store/contact_store.go | 51 +++ internal/store/pg/channel_contacts.go | 154 +++++++ internal/store/pg/factory.go | 1 + internal/store/stores.go | 1 + internal/upgrade/version.go | 2 +- migrations/000014_channel_contacts.down.sql | 1 + migrations/000014_channel_contacts.up.sql | 22 + ui/web/src/components/layout/sidebar.tsx | 2 + ui/web/src/i18n/index.ts | 8 +- ui/web/src/i18n/locales/en/channels.json | 30 +- ui/web/src/i18n/locales/en/contacts.json | 24 + ui/web/src/i18n/locales/en/sidebar.json | 1 + ui/web/src/i18n/locales/vi/channels.json | 30 +- ui/web/src/i18n/locales/vi/contacts.json | 24 + ui/web/src/i18n/locales/vi/sidebar.json | 1 + ui/web/src/i18n/locales/zh/channels.json | 30 +- ui/web/src/i18n/locales/zh/contacts.json | 24 + ui/web/src/i18n/locales/zh/sidebar.json | 1 + ui/web/src/lib/constants.ts | 1 + ui/web/src/lib/query-keys.ts | 4 + .../channel-detail/channel-detail-page.tsx | 26 +- .../channel-detail/channel-managers-tab.tsx | 422 ++++++++++++++++++ .../channel-detail/channel-writers-tab.tsx | 336 -------------- .../channels/hooks/use-channel-detail.ts | 46 +- ui/web/src/pages/contacts/contacts-page.tsx | 184 ++++++++ .../src/pages/contacts/hooks/use-contacts.ts | 48 ++ ui/web/src/routes.tsx | 4 + ui/web/src/types/contact.ts | 14 + 32 files changed, 1204 insertions(+), 416 deletions(-) create mode 100644 internal/store/contact_collector.go create mode 100644 internal/store/contact_store.go create mode 100644 internal/store/pg/channel_contacts.go create mode 100644 migrations/000014_channel_contacts.down.sql create mode 100644 migrations/000014_channel_contacts.up.sql create mode 100644 ui/web/src/i18n/locales/en/contacts.json create mode 100644 ui/web/src/i18n/locales/vi/contacts.json create mode 100644 ui/web/src/i18n/locales/zh/contacts.json create mode 100644 ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx delete mode 100644 ui/web/src/pages/channels/channel-detail/channel-writers-tab.tsx create mode 100644 ui/web/src/pages/contacts/contacts-page.tsx create mode 100644 ui/web/src/pages/contacts/hooks/use-contacts.ts create mode 100644 ui/web/src/types/contact.ts diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 534f37bc..4c9a41fd 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -26,7 +26,7 @@ import ( // and routes them through the scheduler/agent loop, then publishes the response back. // Also handles subagent announcements: routes them through the parent agent's session // (matching TS subagent-announce.ts pattern) so the agent can reformulate for the user. -func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, delegateMgr *tools.DelegateManager, sessStore store.SessionStore, agentStore store.AgentStore) { +func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents *agent.Router, cfg *config.Config, sched *scheduler.Scheduler, channelMgr *channels.Manager, teamStore store.TeamStore, quotaChecker *channels.QuotaChecker, delegateMgr *tools.DelegateManager, sessStore store.SessionStore, agentStore store.AgentStore, contactCollector *store.ContactCollector) { slog.Info("inbound message consumer started") // Inbound message deduplication (matching TS src/infra/dedupe.ts + inbound-dedupe.ts). @@ -110,7 +110,8 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } // Persist friendly names from channel metadata into session + user profile. - if sessionMeta := extractSessionMetadata(msg, peerKind); len(sessionMeta) > 0 { + sessionMeta := extractSessionMetadata(msg, peerKind) + if len(sessionMeta) > 0 { sessStore.SetSessionMetadata(sessionKey, sessionMeta) if agentStore != nil { if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil { @@ -119,6 +120,21 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents } } + // Auto-collect channel contacts for the contact selector. + if contactCollector != nil && msg.SenderID != "" { + senderNumericID := msg.SenderID + if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 { + senderNumericID = senderNumericID[:idx] + } + channelType := channelMgr.ChannelTypeForName(msg.Channel) + if channelType == "" { + channelType = msg.Channel // fallback to instance name + } + displayName := sessionMeta["display_name"] + username := sessionMeta["username"] + contactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind) + } + // --- Quota check --- if quotaChecker != nil { qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName()) diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index b9e4d1e1..2fcbb1a3 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -52,7 +52,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR } if stores != nil && stores.ChannelInstances != nil { - channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, token, msgBus) + channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.Contacts, token, msgBus) } if stores != nil && stores.Providers != nil { diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index 3cf712c7..203c5a65 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -16,15 +16,16 @@ import ( // ChannelInstancesHandler handles channel instance CRUD endpoints. type ChannelInstancesHandler struct { - store store.ChannelInstanceStore - agentStore store.AgentStore - token string - msgBus *bus.MessageBus + store store.ChannelInstanceStore + agentStore store.AgentStore + contactStore store.ContactStore + token string + msgBus *bus.MessageBus } // NewChannelInstancesHandler creates a handler for channel instance management endpoints. -func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { - return &ChannelInstancesHandler{store: s, agentStore: agentStore, token: token, msgBus: msgBus} +func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { + return &ChannelInstancesHandler{store: s, agentStore: agentStore, contactStore: contactStore, token: token, msgBus: msgBus} } // RegisterRoutes registers all channel instance routes on the given mux. @@ -35,6 +36,11 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /v1/channels/instances/{id}", h.auth(h.handleUpdate)) mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.auth(h.handleDelete)) + // Channel contacts (global, not per-agent) + if h.contactStore != nil { + mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts)) + } + // Group file writers (nested under channel instances) if h.agentStore != nil { mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups)) @@ -388,6 +394,58 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) } +// --- Channel contacts --- + +func (h *ChannelInstancesHandler) handleListContacts(w http.ResponseWriter, r *http.Request) { + opts := store.ContactListOpts{ + Limit: 50, + Offset: 0, + } + + if v := r.URL.Query().Get("search"); v != "" { + opts.Search = v + } + if v := r.URL.Query().Get("channel_type"); v != "" { + opts.ChannelType = v + } + if v := r.URL.Query().Get("peer_kind"); v != "" { + opts.PeerKind = v + } + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + opts.Limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + opts.Offset = n + } + } + + contacts, err := h.contactStore.ListContacts(r.Context(), opts) + if err != nil { + slog.Error("contacts.list", "error", err) + locale := store.LocaleFromContext(r.Context()) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")}) + return + } + if contacts == nil { + contacts = []store.ChannelContact{} + } + + total, countErr := h.contactStore.CountContacts(r.Context(), opts) + if countErr != nil { + slog.Warn("contacts.count", "error", countErr) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "contacts": contacts, + "total": total, + "limit": opts.Limit, + "offset": opts.Offset, + }) +} + // isValidChannelType checks if the channel type is supported. func isValidChannelType(ct string) bool { switch ct { diff --git a/internal/store/contact_collector.go b/internal/store/contact_collector.go new file mode 100644 index 00000000..b7cba9e4 --- /dev/null +++ b/internal/store/contact_collector.go @@ -0,0 +1,36 @@ +package store + +import ( + "context" + "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/cache" +) + +const contactSeenTTL = 30 * time.Minute + +// ContactCollector wraps ContactStore with an in-memory "seen" cache +// to avoid redundant UPSERT queries on every message. +type ContactCollector struct { + store ContactStore + seen cache.Cache[bool] +} + +// NewContactCollector creates a new collector backed by the given store and cache. +func NewContactCollector(s ContactStore, c cache.Cache[bool]) *ContactCollector { + return &ContactCollector{store: s, seen: c} +} + +// EnsureContact creates or refreshes a contact entry, skipping DB if recently seen. +func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) { + key := channelType + ":" + senderID + if _, ok := c.seen.Get(ctx, key); ok { + return + } + if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind); err != nil { + slog.Warn("contact_collector.upsert_failed", "error", err, "channel", channelType, "sender", senderID) + return + } + c.seen.Set(ctx, key, true, contactSeenTTL) +} diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go new file mode 100644 index 00000000..bdc470c4 --- /dev/null +++ b/internal/store/contact_store.go @@ -0,0 +1,51 @@ +package store + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// ChannelContact represents a user discovered through channel interactions. +// Global (not per-agent): same person on the same platform = one row. +type ChannelContact struct { + ID uuid.UUID `json:"id"` + ChannelType string `json:"channel_type"` + ChannelInstance *string `json:"channel_instance,omitempty"` + SenderID string `json:"sender_id"` + UserID *string `json:"user_id,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Username *string `json:"username,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + PeerKind *string `json:"peer_kind,omitempty"` + MergedID *uuid.UUID `json:"merged_id,omitempty"` + FirstSeenAt time.Time `json:"first_seen_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +// ContactListOpts holds pagination and filter options for listing contacts. +type ContactListOpts struct { + Search string // ILIKE on display_name, username, sender_id + ChannelType string // filter by platform (telegram, discord, etc.) + PeerKind string // "direct" or "group" + Limit int + Offset int +} + +// ContactStore manages channel contacts (auto-collected user info). +type ContactStore interface { + // UpsertContact creates or updates a contact. On conflict (channel_type, sender_id), + // updates display_name, username, user_id, channel_instance, and last_seen_at. + UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error + + // ListContacts searches contacts with pagination and filters. + ListContacts(ctx context.Context, opts ContactListOpts) ([]ChannelContact, error) + + // CountContacts returns total matching contacts for the given filters. + CountContacts(ctx context.Context, opts ContactListOpts) (int, error) + + // MergeContacts assigns the same merged_id to all given contact IDs, + // linking them as the same person across channels. + MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error +} diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go new file mode 100644 index 00000000..439ad88a --- /dev/null +++ b/internal/store/pg/channel_contacts.go @@ -0,0 +1,154 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGContactStore implements store.ContactStore backed by Postgres. +type PGContactStore struct { + db *sql.DB +} + +// NewPGContactStore creates a new PGContactStore. +func NewPGContactStore(db *sql.DB) *PGContactStore { + return &PGContactStore{db: db} +} + +func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind) + VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,'')) + ON CONFLICT (channel_type, sender_id) DO UPDATE SET + display_name = COALESCE(NULLIF($5,''), channel_contacts.display_name), + username = COALESCE(NULLIF($6,''), channel_contacts.username), + user_id = COALESCE(NULLIF($4,''), channel_contacts.user_id), + channel_instance = COALESCE(NULLIF($2,''), channel_contacts.channel_instance), + peer_kind = COALESCE(NULLIF($7,''), channel_contacts.peer_kind), + last_seen_at = NOW()`, + channelType, channelInstance, senderID, userID, displayName, username, peerKind, + ) + return err +} + +func contactWhereClause(opts store.ContactListOpts) (string, []any, int) { + var conditions []string + var args []any + argIdx := 1 + + if opts.ChannelType != "" { + conditions = append(conditions, fmt.Sprintf("channel_type = $%d", argIdx)) + args = append(args, opts.ChannelType) + argIdx++ + } + if opts.PeerKind != "" { + conditions = append(conditions, fmt.Sprintf("peer_kind = $%d", argIdx)) + args = append(args, opts.PeerKind) + argIdx++ + } + if opts.Search != "" { + escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) + pattern := "%" + escaped + "%" + conditions = append(conditions, fmt.Sprintf( + "(display_name ILIKE $%d OR username ILIKE $%d OR sender_id ILIKE $%d)", + argIdx, argIdx, argIdx, + )) + args = append(args, pattern) + argIdx++ + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + return where, args, argIdx +} + +func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { + where, args, argIdx := contactWhereClause(opts) + + query := `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts` + where + " ORDER BY last_seen_at DESC" + + limit := opts.Limit + if limit <= 0 { + limit = 50 + } + query += fmt.Sprintf(" LIMIT $%d", argIdx) + args = append(args, limit) + argIdx++ + + if opts.Offset > 0 { + query += fmt.Sprintf(" OFFSET $%d", argIdx) + args = append(args, opts.Offset) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []store.ChannelContact + for rows.Next() { + var c store.ChannelContact + if err := rows.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, rows.Err() +} + +func (s *PGContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) { + where, args, _ := contactWhereClause(opts) + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count) + return count, err +} + +func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { + if len(contactIDs) < 2 { + return nil + } + + // Check if any of the contacts already has a merged_id; reuse it. + placeholders := make([]string, len(contactIDs)) + args := make([]any, len(contactIDs)) + for i, id := range contactIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + inClause := strings.Join(placeholders, ",") + + var existingMergedID *uuid.UUID + err := s.db.QueryRowContext(ctx, + fmt.Sprintf("SELECT merged_id FROM channel_contacts WHERE id IN (%s) AND merged_id IS NOT NULL LIMIT 1", inClause), + args..., + ).Scan(&existingMergedID) + + mergedID := uuid.New() + if err == nil && existingMergedID != nil { + mergedID = *existingMergedID + } + + // Update all contacts with the merged_id. + args = append(args, mergedID) + _, err = s.db.ExecContext(ctx, + fmt.Sprintf("UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s)", len(args), inClause), + args..., + ) + return err +} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index 7b27fc4f..112de531 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -41,5 +41,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { BuiltinTools: NewPGBuiltinToolStore(db), PendingMessages: NewPGPendingMessageStore(db), KnowledgeGraph: NewPGKnowledgeGraphStore(db), + Contacts: NewPGContactStore(db), }, nil } diff --git a/internal/store/stores.go b/internal/store/stores.go index f3611cb7..e6a6e080 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -22,4 +22,5 @@ type Stores struct { BuiltinTools BuiltinToolStore PendingMessages PendingMessageStore KnowledgeGraph KnowledgeGraphStore + Contacts ContactStore } diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index fcbe1811..554af766 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 13 +const RequiredSchemaVersion uint = 14 diff --git a/migrations/000014_channel_contacts.down.sql b/migrations/000014_channel_contacts.down.sql new file mode 100644 index 00000000..46f0ed02 --- /dev/null +++ b/migrations/000014_channel_contacts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS channel_contacts; diff --git a/migrations/000014_channel_contacts.up.sql b/migrations/000014_channel_contacts.up.sql new file mode 100644 index 00000000..91b20a85 --- /dev/null +++ b/migrations/000014_channel_contacts.up.sql @@ -0,0 +1,22 @@ +-- Channel contacts: auto-collected user info from all channels. +-- Global (not per-agent). Used for contact selector, future RBAC, analytics. +CREATE TABLE IF NOT EXISTS channel_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_type VARCHAR(50) NOT NULL, + channel_instance VARCHAR(255), + sender_id VARCHAR(255) NOT NULL, + user_id VARCHAR(255), + display_name VARCHAR(255), + username VARCHAR(255), + avatar_url TEXT, + peer_kind VARCHAR(20), + metadata JSONB DEFAULT '{}', + merged_id UUID, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (channel_type, sender_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_contacts_instance ON channel_contacts(channel_instance) WHERE channel_instance IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channel_contacts_merged ON channel_contacts(merged_id) WHERE merged_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_channel_contacts_search ON channel_contacts(display_name, username); diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 4896797e..38bdc765 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -23,6 +23,7 @@ import { HardDrive, Inbox, Brain, + Contact, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -79,6 +80,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index d8f6d7cd..11960312 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -30,6 +30,7 @@ import enSetup from "./locales/en/setup.json"; import enMemory from "./locales/en/memory.json"; import enStorage from "./locales/en/storage.json"; import enPendingMessages from "./locales/en/pending-messages.json"; +import enContacts from "./locales/en/contacts.json"; // --- VI namespaces --- import viCommon from "./locales/vi/common.json"; @@ -60,6 +61,7 @@ import viSetup from "./locales/vi/setup.json"; import viMemory from "./locales/vi/memory.json"; import viStorage from "./locales/vi/storage.json"; import viPendingMessages from "./locales/vi/pending-messages.json"; +import viContacts from "./locales/vi/contacts.json"; // --- ZH namespaces --- import zhCommon from "./locales/zh/common.json"; @@ -90,6 +92,7 @@ import zhSetup from "./locales/zh/setup.json"; import zhMemory from "./locales/zh/memory.json"; import zhStorage from "./locales/zh/storage.json"; import zhPendingMessages from "./locales/zh/pending-messages.json"; +import zhContacts from "./locales/zh/contacts.json"; const STORAGE_KEY = "goclaw:language"; @@ -107,7 +110,7 @@ const ns = [ "agents", "teams", "sessions", "skills", "cron", "config", "channels", "providers", "traces", "events", "delegations", "usage", "approvals", "nodes", "logs", "tools", "mcp", "tts", - "setup", "memory", "storage", "pending-messages", + "setup", "memory", "storage", "pending-messages", "contacts", ] as const; i18n.use(initReactI18next).init({ @@ -121,6 +124,7 @@ i18n.use(initReactI18next).init({ approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools, mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage, "pending-messages": enPendingMessages, + contacts: enContacts, }, vi: { common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin, @@ -131,6 +135,7 @@ i18n.use(initReactI18next).init({ approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools, mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage, "pending-messages": viPendingMessages, + contacts: viContacts, }, zh: { common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin, @@ -141,6 +146,7 @@ i18n.use(initReactI18next).init({ approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools, mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage, "pending-messages": zhPendingMessages, + contacts: zhContacts, }, }, ns: [...ns], diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index ff1322d7..eff1642f 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -79,7 +79,7 @@ "config": "Config", "credentials": "Credentials", "groups": "Groups", - "writers": "Writers" + "managers": "Managers" }, "general": { "name": "Name", @@ -114,37 +114,39 @@ "saveGroups": "Save Groups", "saving": "Saving..." }, - "writers": { - "description": "Manage which users are allowed to write/edit files in group chats.", + "managers": { + "description": "Manage which users have manager permissions in group chats.", "groups": "Groups", - "noWriterGroups": "No writer groups", - "noWriterGroupsHint": "Use the form below to add writers to a group.", - "loadingWriters": "Loading writers...", - "noWriters": "No writers in this group.", - "writersCount": "{{count}} writer", - "writersCountPlural": "{{count}} writers", + "noManagerGroups": "No manager groups", + "noManagerGroupsHint": "Use the form below to add a manager to a group.", + "loadingManagers": "Loading managers...", + "noManagers": "No managers in this group.", + "managersCount": "{{count}} manager", + "managersCountPlural": "{{count}} managers", + "searchContacts": "Search contacts...", + "noResults": "No contacts found", "columns": { "userId": "User ID", "name": "Name", "username": "Username" }, "addForm": { - "title": "Add Writer to New Group", - "hint": "Add a writer to a group that doesn't appear in the list above.", + "title": "Add Manager to New Group", + "hint": "Add a manager to a group that doesn't appear in the list above.", "groupId": "Group ID *", "groupIdPlaceholder": "e.g. group:telegram:-100123456", "userId": "User ID *", - "userIdPlaceholder": "e.g. 12345678", + "userIdPlaceholder": "Search or enter user ID", "displayName": "Display Name", "username": "Username", "optional": "Optional", "usernameWithout": "Optional (without @)", "adding": "Adding...", - "addWriter": "Add Writer", + "addManager": "Add Manager", "add": "Add", "errors": { "groupUserRequired": "Group ID and User ID are required", - "failedAdd": "Failed to add writer" + "failedAdd": "Failed to add manager" } } } diff --git a/ui/web/src/i18n/locales/en/contacts.json b/ui/web/src/i18n/locales/en/contacts.json new file mode 100644 index 00000000..9ee1fccb --- /dev/null +++ b/ui/web/src/i18n/locales/en/contacts.json @@ -0,0 +1,24 @@ +{ + "title": "Contacts", + "description": "Auto-collected contacts from all channel interactions.", + "searchPlaceholder": "Search by name, username, or ID...", + "filter": "Filter", + "emptyTitle": "No contacts", + "emptyDescription": "Contacts will appear here as users interact with your agents through channels.", + "noMatchTitle": "No matching contacts", + "noMatchDescription": "Try a different search term or filter.", + "columns": { + "name": "Name", + "username": "Username", + "senderId": "Sender ID", + "channelType": "Channel", + "peerKind": "Type", + "lastSeen": "Last Seen" + }, + "filters": { + "allChannels": "All Channels", + "allTypes": "All Types", + "direct": "Direct", + "group": "Group" + } +} diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index c1f05979..00c2c93c 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -13,6 +13,7 @@ "sessions": "Sessions", "pendingMessages": "Pending Messages", "channels": "Channels", + "contacts": "Contacts", "skills": "Skills", "cron": "Cron", "customTools": "Custom Tools", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 3a0048b2..df09ddfa 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -79,7 +79,7 @@ "config": "Cấu hình", "credentials": "Thông tin xác thực", "groups": "Nhóm", - "writers": "Người viết" + "managers": "Quản lý" }, "general": { "name": "Tên", @@ -114,37 +114,39 @@ "saveGroups": "Lưu nhóm", "saving": "Đang lưu..." }, - "writers": { - "description": "Quản lý người dùng được phép viết/chỉnh sửa tệp trong cuộc trò chuyện nhóm.", + "managers": { + "description": "Quản lý người dùng có quyền quản lý trong cuộc trò chuyện nhóm.", "groups": "Nhóm", - "noWriterGroups": "Không có nhóm người viết", - "noWriterGroupsHint": "Dùng biểu mẫu bên dưới để thêm người viết vào nhóm.", - "loadingWriters": "Đang tải người viết...", - "noWriters": "Không có người viết trong nhóm này.", - "writersCount": "{{count}} người viết", - "writersCountPlural": "{{count}} người viết", + "noManagerGroups": "Không có nhóm quản lý", + "noManagerGroupsHint": "Dùng biểu mẫu bên dưới để thêm quản lý viên vào nhóm.", + "loadingManagers": "Đang tải quản lý viên...", + "noManagers": "Không có quản lý viên trong nhóm này.", + "managersCount": "{{count}} quản lý viên", + "managersCountPlural": "{{count}} quản lý viên", + "searchContacts": "Tìm kiếm liên hệ...", + "noResults": "Không tìm thấy liên hệ", "columns": { "userId": "ID người dùng", "name": "Tên", "username": "Tên đăng nhập" }, "addForm": { - "title": "Thêm người viết vào nhóm mới", - "hint": "Thêm người viết vào nhóm chưa xuất hiện trong danh sách trên.", + "title": "Thêm quản lý viên vào nhóm mới", + "hint": "Thêm quản lý viên vào nhóm chưa xuất hiện trong danh sách trên.", "groupId": "ID nhóm *", "groupIdPlaceholder": "vd: group:telegram:-100123456", "userId": "ID người dùng *", - "userIdPlaceholder": "vd: 12345678", + "userIdPlaceholder": "Tìm hoặc nhập ID người dùng", "displayName": "Tên hiển thị", "username": "Tên đăng nhập", "optional": "Tùy chọn", "usernameWithout": "Tùy chọn (không có @)", "adding": "Đang thêm...", - "addWriter": "Thêm người viết", + "addManager": "Thêm quản lý viên", "add": "Thêm", "errors": { "groupUserRequired": "ID nhóm và ID người dùng là bắt buộc", - "failedAdd": "Không thể thêm người viết" + "failedAdd": "Không thể thêm quản lý viên" } } } diff --git a/ui/web/src/i18n/locales/vi/contacts.json b/ui/web/src/i18n/locales/vi/contacts.json new file mode 100644 index 00000000..d0520e31 --- /dev/null +++ b/ui/web/src/i18n/locales/vi/contacts.json @@ -0,0 +1,24 @@ +{ + "title": "Liên hệ", + "description": "Liên hệ tự động thu thập từ tất cả tương tác qua các channel.", + "searchPlaceholder": "Tìm theo tên, username hoặc ID...", + "filter": "Lọc", + "emptyTitle": "Không có liên hệ", + "emptyDescription": "Liên hệ sẽ xuất hiện khi người dùng tương tác với agent qua các channel.", + "noMatchTitle": "Không tìm thấy liên hệ phù hợp", + "noMatchDescription": "Thử từ khóa tìm kiếm hoặc bộ lọc khác.", + "columns": { + "name": "Tên", + "username": "Tên đăng nhập", + "senderId": "ID gửi", + "channelType": "Channel", + "peerKind": "Loại", + "lastSeen": "Lần cuối" + }, + "filters": { + "allChannels": "Tất cả Channel", + "allTypes": "Tất cả loại", + "direct": "Trực tiếp", + "group": "Nhóm" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index 987da203..920c6bb4 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -13,6 +13,7 @@ "sessions": "Session", "pendingMessages": "Tin nhắn chờ", "channels": "Channel", + "contacts": "Liên hệ", "skills": "Skill", "cron": "Tác vụ định kỳ", "customTools": "Công cụ tùy chỉnh", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 19178648..b68f41e0 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -79,7 +79,7 @@ "config": "配置", "credentials": "凭据", "groups": "群组", - "writers": "写入权限" + "managers": "管理员" }, "general": { "name": "名称", @@ -114,37 +114,39 @@ "saveGroups": "保存群组", "saving": "保存中..." }, - "writers": { - "description": "管理允许在群组聊天中写入/编辑文件的用户。", + "managers": { + "description": "管理群组聊天中具有管理员权限的用户。", "groups": "群组", - "noWriterGroups": "暂无写入权限群组", - "noWriterGroupsHint": "使用下方表单将写入权限添加到群组。", - "loadingWriters": "正在加载写入者...", - "noWriters": "此群组中没有写入者。", - "writersCount": "{{count}} 位写入者", - "writersCountPlural": "{{count}} 位写入者", + "noManagerGroups": "暂无管理员群组", + "noManagerGroupsHint": "使用下方表单将管理员添加到群组。", + "loadingManagers": "正在加载管理员...", + "noManagers": "此群组中没有管理员。", + "managersCount": "{{count}} 位管理员", + "managersCountPlural": "{{count}} 位管理员", + "searchContacts": "搜索联系人...", + "noResults": "未找到联系人", "columns": { "userId": "用户 ID", "name": "名称", "username": "用户名" }, "addForm": { - "title": "将写入者添加到新群组", - "hint": "将写入者添加到上方列表中未出现的群组。", + "title": "将管理员添加到新群组", + "hint": "将管理员添加到上方列表中未出现的群组。", "groupId": "群组 ID *", "groupIdPlaceholder": "例如 group:telegram:-100123456", "userId": "用户 ID *", - "userIdPlaceholder": "例如 12345678", + "userIdPlaceholder": "搜索或输入用户 ID", "displayName": "显示名称", "username": "用户名", "optional": "可选", "usernameWithout": "可选(不含 @)", "adding": "添加中...", - "addWriter": "添加写入者", + "addManager": "添加管理员", "add": "添加", "errors": { "groupUserRequired": "群组 ID 和用户 ID 为必填项", - "failedAdd": "添加写入者失败" + "failedAdd": "添加管理员失败" } } } diff --git a/ui/web/src/i18n/locales/zh/contacts.json b/ui/web/src/i18n/locales/zh/contacts.json new file mode 100644 index 00000000..37964017 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/contacts.json @@ -0,0 +1,24 @@ +{ + "title": "联系人", + "description": "从所有Channel交互中自动收集的联系人。", + "searchPlaceholder": "按名称、用户名或 ID 搜索...", + "filter": "筛选", + "emptyTitle": "暂无联系人", + "emptyDescription": "当用户通过Channel与Agent交互时,联系人将自动出现在此处。", + "noMatchTitle": "未找到匹配的联系人", + "noMatchDescription": "请尝试其他搜索词或筛选条件。", + "columns": { + "name": "名称", + "username": "用户名", + "senderId": "发送者 ID", + "channelType": "Channel", + "peerKind": "类型", + "lastSeen": "最后活跃" + }, + "filters": { + "allChannels": "所有Channel", + "allTypes": "所有类型", + "direct": "私聊", + "group": "群组" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index 346341bd..fbf0e539 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -11,6 +11,7 @@ "approvals": "审批", "builtinTools": "内置工具", "channels": "Channel", + "contacts": "联系人", "chat": "对话", "config": "配置", "cron": "定时任务", diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts index 6c4af913..855d7290 100644 --- a/ui/web/src/lib/constants.ts +++ b/ui/web/src/lib/constants.ts @@ -19,6 +19,7 @@ export const ROUTES = { USAGE: "/usage", CHANNELS: "/channels", CHANNEL_DETAIL: "/channels/:id", + CONTACTS: "/contacts", APPROVALS: "/approvals", NODES: "/nodes", LOGS: "/logs", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 610c6281..ac4912ec 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -30,6 +30,10 @@ export const queryKeys = { list: (params: Record) => ["channels", params] as const, detail: (id: string) => ["channels", "detail", id] as const, }, + contacts: { + all: ["contacts"] as const, + list: (params: Record) => ["contacts", params] as const, + }, skills: { all: ["skills"] as const, agentGrants: (agentId: string) => ["skills", "agent", agentId] as const, diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 06982289..922c1084 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -10,7 +10,7 @@ import { ChannelGeneralTab } from "./channel-general-tab"; import { ChannelCredentialsTab } from "./channel-credentials-tab"; import { ChannelConfigTab } from "./channel-config-tab"; import { ChannelGroupsTab } from "./channel-groups-tab"; -import { ChannelWritersTab } from "./channel-writers-tab"; +import { ChannelManagersTab } from "./channel-managers-tab"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; import { channelTypeLabels } from "../channels-status-view"; import { useChannels } from "../hooks/use-channels"; @@ -26,10 +26,11 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps instance, loading, updateInstance, - listWriterGroups, - listWriters, - addWriter, - removeWriter, + listManagerGroups, + listManagers, + addManager, + removeManager, + listContacts, } = useChannelDetail(instanceId); const { agents } = useAgents(); const { channels } = useChannels(); @@ -95,7 +96,7 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps {t("detail.tabs.credentials")} {t("detail.tabs.config")} {isTelegram && {t("detail.tabs.groups")}} - {t("detail.tabs.writers")} + {t("detail.tabs.managers")} @@ -129,12 +130,13 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps )} - - + diff --git a/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx new file mode 100644 index 00000000..e8a93464 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-managers-tab.tsx @@ -0,0 +1,422 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { EmptyState } from "@/components/shared/empty-state"; +import type { GroupManagerGroupInfo, GroupManagerData, ChannelContact } from "../hooks/use-channel-detail"; + +interface ChannelManagersTabProps { + listManagerGroups: () => Promise; + listManagers: (groupId: string) => Promise; + addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise; + removeManager: (groupId: string, userId: string) => Promise; + listContacts: (search: string, channelType?: string) => Promise; +} + +/** Strips the "group::" prefix for display, e.g. "group:telegram:-100123" → "-100123" */ +function shortGroupId(id: string): string { + const m = id.match(/^group:[^:]+:(.+)$/); + return m?.[1] ?? id; +} + +function contactToOption(c: ChannelContact): ComboboxOption { + const parts: string[] = []; + if (c.display_name) parts.push(c.display_name); + if (c.username) parts.push(`@${c.username}`); + parts.push(`(${c.sender_id})`); + return { value: c.sender_id, label: parts.join(" — ") }; +} + +// --- Inline add form (scoped state per instance) --- + +interface InlineAddFormProps { + groupId?: string; + showGroupField?: boolean; + listContacts: (search: string) => Promise; + onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise; +} + +function InlineAddForm({ groupId, showGroupField, listContacts, onAdd }: InlineAddFormProps) { + const { t } = useTranslation("channels"); + const [formGroupId, setFormGroupId] = useState(""); + const [userId, setUserId] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [username, setUsername] = useState(""); + const [adding, setAdding] = useState(false); + const [error, setError] = useState(""); + const [contactOptions, setContactOptions] = useState([]); + const contactCacheRef = useRef>({}); + const debounceRef = useRef>(undefined); + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const searchContacts = useCallback( + (query: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (!query || query.length < 2) { + setContactOptions([]); + return; + } + debounceRef.current = setTimeout(async () => { + try { + const contacts = await listContacts(query); + const cache: Record = {}; + const opts = contacts.map((c) => { + cache[c.sender_id] = c; + return contactToOption(c); + }); + contactCacheRef.current = { ...contactCacheRef.current, ...cache }; + setContactOptions(opts); + } catch { + setContactOptions([]); + } + }, 300); + }, + [listContacts], + ); + + const handleUserIdChange = (val: string) => { + setUserId(val); + const contact = contactCacheRef.current[val]; + if (contact) { + setDisplayName(contact.display_name ?? ""); + setUsername(contact.username ?? ""); + } + searchContacts(val); + }; + + const handleSubmit = async () => { + const gid = groupId || formGroupId.trim(); + const uid = userId.trim(); + if (!gid || !uid) { + setError(t("detail.managers.addForm.errors.groupUserRequired")); + return; + } + setAdding(true); + setError(""); + try { + await onAdd(gid, uid, displayName.trim(), username.trim()); + setUserId(""); + setDisplayName(""); + setUsername(""); + setContactOptions([]); + if (!groupId) setFormGroupId(""); + } catch (err) { + setError(err instanceof Error ? err.message : t("detail.managers.addForm.errors.failedAdd")); + } finally { + setAdding(false); + } + }; + + if (showGroupField) { + return ( +
+ {t("detail.managers.addForm.title")} +

{t("detail.managers.addForm.hint")}

+
+
+ + setFormGroupId(e.target.value)} + placeholder={t("detail.managers.addForm.groupIdPlaceholder")} + className="text-sm" + /> +
+
+ + +
+
+ + setDisplayName(e.target.value)} + placeholder={t("detail.managers.addForm.optional")} + className="text-sm" + /> +
+
+ + setUsername(e.target.value)} + placeholder={t("detail.managers.addForm.usernameWithout")} + className="text-sm" + /> +
+
+ {error &&

{error}

} +
+ +
+
+ ); + } + + return ( +
+
+ + +
+
+ + setDisplayName(e.target.value)} + placeholder={t("detail.managers.addForm.optional")} + className="h-8 text-sm" + /> +
+
+ + setUsername(e.target.value)} + placeholder={t("detail.managers.addForm.optional")} + className="h-8 text-sm" + /> +
+ + {error &&

{error}

} +
+ ); +} + +// --- Main tab component --- + +export function ChannelManagersTab({ + listManagerGroups, + listManagers, + addManager, + removeManager, + listContacts, +}: ChannelManagersTabProps) { + const { t } = useTranslation("channels"); + const [groups, setGroups] = useState([]); + const [loadingGroups, setLoadingGroups] = useState(true); + + const [expanded, setExpanded] = useState>({}); + const [managersMap, setManagersMap] = useState>({}); + const [loadingMap, setLoadingMap] = useState>({}); + + const refreshGroups = useCallback(async () => { + setLoadingGroups(true); + try { + const g = await listManagerGroups(); + setGroups(g); + } catch { + // handled by http hook + } finally { + setLoadingGroups(false); + } + }, [listManagerGroups]); + + useEffect(() => { + refreshGroups(); + }, [refreshGroups]); + + const loadManagersForGroup = useCallback( + async (groupId: string) => { + setLoadingMap((prev) => ({ ...prev, [groupId]: true })); + try { + const w = await listManagers(groupId); + setManagersMap((prev) => ({ ...prev, [groupId]: w })); + } catch { + setManagersMap((prev) => ({ ...prev, [groupId]: [] })); + } finally { + setLoadingMap((prev) => ({ ...prev, [groupId]: false })); + } + }, + [listManagers], + ); + + const toggleGroup = (groupId: string) => { + const isExpanding = !expanded[groupId]; + setExpanded((prev) => ({ ...prev, [groupId]: isExpanding })); + if (isExpanding && !managersMap[groupId]) { + loadManagersForGroup(groupId); + } + }; + + const handleRemoveManager = async (groupId: string, userId: string) => { + try { + await removeManager(groupId, userId); + setManagersMap((prev) => ({ + ...prev, + [groupId]: (prev[groupId] ?? []).filter((w) => w.user_id !== userId), + })); + await refreshGroups(); + } catch { + // handled by http hook + } + }; + + const handleAddManager = async (gid: string, uid: string, dn: string, un: string) => { + await addManager(gid, uid, dn, un); + if (expanded[gid]) { + await loadManagersForGroup(gid); + } + await refreshGroups(); + if (!expanded[gid]) { + setExpanded((prev) => ({ ...prev, [gid]: true })); + loadManagersForGroup(gid); + } + }; + + return ( +
+

+ {t("detail.managers.description")} +

+ + {/* Groups accordion */} +
+
+

+ {t("detail.managers.groups")} + {groups.length > 0 && ( + ({groups.length}) + )} +

+ +
+ + {groups.length === 0 && !loadingGroups ? ( + + ) : ( +
+ {groups.map((g) => { + const isOpen = expanded[g.group_id]; + const groupManagers = managersMap[g.group_id] ?? []; + const isLoading = loadingMap[g.group_id]; + + return ( +
+ + + {isOpen && ( +
+ {isLoading ? ( +

{t("detail.managers.loadingManagers")}

+ ) : groupManagers.length === 0 ? ( +

{t("detail.managers.noManagers")}

+ ) : ( +
+ + + + + + + + + + {groupManagers.map((w) => ( + + + + + + + ))} + +
{t("detail.managers.columns.userId")}{t("detail.managers.columns.name")}{t("detail.managers.columns.username")} +
{w.user_id}{w.display_name || -}{w.username ? @{w.username} : -} + +
+
+ )} + + +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Add to new group */} + +
+ ); +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-writers-tab.tsx b/ui/web/src/pages/channels/channel-detail/channel-writers-tab.tsx deleted file mode 100644 index 305b7ec0..00000000 --- a/ui/web/src/pages/channels/channel-detail/channel-writers-tab.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Plus, Trash2, RefreshCw, Users, ChevronDown, ChevronRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { EmptyState } from "@/components/shared/empty-state"; -import type { GroupWriterGroupInfo, GroupFileWriterData } from "../hooks/use-channel-detail"; - -interface ChannelWritersTabProps { - listWriterGroups: () => Promise; - listWriters: (groupId: string) => Promise; - addWriter: (groupId: string, userId: string, displayName?: string, username?: string) => Promise; - removeWriter: (groupId: string, userId: string) => Promise; -} - -/** Strips the "group::" prefix for display, e.g. "group:telegram:-100123" → "-100123" */ -function shortGroupId(id: string): string { - const m = id.match(/^group:[^:]+:(.+)$/); - return m?.[1] ?? id; -} - -export function ChannelWritersTab({ - listWriterGroups, - listWriters, - addWriter, - removeWriter, -}: ChannelWritersTabProps) { - const { t } = useTranslation("channels"); - const [groups, setGroups] = useState([]); - const [loadingGroups, setLoadingGroups] = useState(true); - - // Per-group expanded state & cached writers - const [expanded, setExpanded] = useState>({}); - const [writersMap, setWritersMap] = useState>({}); - const [loadingMap, setLoadingMap] = useState>({}); - - // Add writer form - const [addGroupId, setAddGroupId] = useState(""); - const [addUserId, setAddUserId] = useState(""); - const [addDisplayName, setAddDisplayName] = useState(""); - const [addUsername, setAddUsername] = useState(""); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(""); - - const refreshGroups = useCallback(async () => { - setLoadingGroups(true); - try { - const g = await listWriterGroups(); - setGroups(g); - } catch { - // handled by http hook - } finally { - setLoadingGroups(false); - } - }, [listWriterGroups]); - - useEffect(() => { - refreshGroups(); - }, [refreshGroups]); - - const loadWritersForGroup = useCallback( - async (groupId: string) => { - setLoadingMap((prev) => ({ ...prev, [groupId]: true })); - try { - const w = await listWriters(groupId); - setWritersMap((prev) => ({ ...prev, [groupId]: w })); - } catch { - setWritersMap((prev) => ({ ...prev, [groupId]: [] })); - } finally { - setLoadingMap((prev) => ({ ...prev, [groupId]: false })); - } - }, - [listWriters], - ); - - const toggleGroup = (groupId: string) => { - const isExpanding = !expanded[groupId]; - setExpanded((prev) => ({ ...prev, [groupId]: isExpanding })); - if (isExpanding && !writersMap[groupId]) { - loadWritersForGroup(groupId); - } - }; - - const handleRemoveWriter = async (groupId: string, userId: string) => { - try { - await removeWriter(groupId, userId); - setWritersMap((prev) => ({ - ...prev, - [groupId]: (prev[groupId] ?? []).filter((w) => w.user_id !== userId), - })); - await refreshGroups(); - } catch { - // handled by http hook - } - }; - - const handleAddWriter = async (targetGroupId?: string) => { - const gid = targetGroupId || addGroupId.trim(); - const uid = addUserId.trim(); - if (!gid || !uid) { - setError(t("detail.writers.addForm.errors.groupUserRequired")); - return; - } - setAdding(true); - setError(""); - try { - await addWriter(gid, uid, addDisplayName.trim(), addUsername.trim()); - setAddUserId(""); - setAddDisplayName(""); - setAddUsername(""); - if (!targetGroupId) setAddGroupId(""); - // Reload writers for this group if it's expanded - if (expanded[gid] || targetGroupId) { - await loadWritersForGroup(gid); - } - await refreshGroups(); - // Auto-expand the group we just added to - if (!expanded[gid]) { - setExpanded((prev) => ({ ...prev, [gid]: true })); - } - } catch (err) { - setError(err instanceof Error ? err.message : t("detail.writers.addForm.errors.failedAdd")); - } finally { - setAdding(false); - } - }; - - return ( -
-

- {t("detail.writers.description")} -

- - {/* Groups accordion */} -
-
-

- {t("detail.writers.groups")} - {groups.length > 0 && ( - ({groups.length}) - )} -

- -
- - {groups.length === 0 && !loadingGroups ? ( - - ) : ( -
- {groups.map((g) => { - const isOpen = expanded[g.group_id]; - const groupWriters = writersMap[g.group_id] ?? []; - const isLoading = loadingMap[g.group_id]; - - return ( -
- {/* Group header row */} - - - {/* Expanded: writers list */} - {isOpen && ( -
- {isLoading ? ( -

{t("detail.writers.loadingWriters")}

- ) : groupWriters.length === 0 ? ( -

{t("detail.writers.noWriters")}

- ) : ( -
- - - - - - - - - - {groupWriters.map((w) => ( - - - - - - - ))} - -
{t("detail.writers.columns.userId")}{t("detail.writers.columns.name")}{t("detail.writers.columns.username")} -
{w.user_id}{w.display_name || -}{w.username ? @{w.username} : -} - -
-
- )} - - {/* Inline add form for this group */} -
-
- - setAddUserId(e.target.value)} - placeholder={t("detail.writers.addForm.userIdPlaceholder")} - className="h-8 text-sm" - onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddWriter(g.group_id))} - /> -
-
- - setAddDisplayName(e.target.value)} - placeholder={t("detail.writers.addForm.optional")} - className="h-8 text-sm" - /> -
-
- - setAddUsername(e.target.value)} - placeholder={t("detail.writers.addForm.optional")} - className="h-8 text-sm" - /> -
- -
-
- )} -
- ); - })} -
- )} -
- - {/* Add to new group */} -
- {t("detail.writers.addForm.title")} -

{t("detail.writers.addForm.hint")}

-
-
- - setAddGroupId(e.target.value)} - placeholder={t("detail.writers.addForm.groupIdPlaceholder")} - className="text-sm" - /> -
-
- - setAddUserId(e.target.value)} - placeholder={t("detail.writers.addForm.userIdPlaceholder")} - className="text-sm" - /> -
-
- - setAddDisplayName(e.target.value)} - placeholder={t("detail.writers.addForm.optional")} - className="text-sm" - /> -
-
- - setAddUsername(e.target.value)} - placeholder={t("detail.writers.addForm.usernameWithout")} - className="text-sm" - /> -
-
- {error &&

{error}

} -
- -
-
-
- ); -} diff --git a/ui/web/src/pages/channels/hooks/use-channel-detail.ts b/ui/web/src/pages/channels/hooks/use-channel-detail.ts index 3ef5fda1..3daaf367 100644 --- a/ui/web/src/pages/channels/hooks/use-channel-detail.ts +++ b/ui/web/src/pages/channels/hooks/use-channel-detail.ts @@ -3,13 +3,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useHttp } from "@/hooks/use-ws"; import { queryKeys } from "@/lib/query-keys"; import type { ChannelInstanceData } from "@/types/channel"; +import type { ChannelContact } from "@/types/contact"; -export interface GroupWriterGroupInfo { +export type { ChannelContact }; + +export interface GroupManagerGroupInfo { group_id: string; writer_count: number; } -export interface GroupFileWriterData { +export interface GroupManagerData { user_id: string; display_name?: string; username?: string; @@ -43,26 +46,26 @@ export function useChannelDetail(instanceId: string | undefined) { [instanceId, http, invalidate], ); - // Writers API - const listWriterGroups = useCallback( - async (): Promise => { + // Managers API (backend routes still use /writers paths) + const listManagerGroups = useCallback( + async (): Promise => { if (!instanceId) return []; - const res = await http.get<{ groups: GroupWriterGroupInfo[] }>(`/v1/channels/instances/${instanceId}/writers/groups`); + const res = await http.get<{ groups: GroupManagerGroupInfo[] }>(`/v1/channels/instances/${instanceId}/writers/groups`); return res.groups ?? []; }, [instanceId, http], ); - const listWriters = useCallback( - async (groupId: string): Promise => { + const listManagers = useCallback( + async (groupId: string): Promise => { if (!instanceId) return []; - const res = await http.get<{ writers: GroupFileWriterData[] }>(`/v1/channels/instances/${instanceId}/writers`, { group_id: groupId }); + const res = await http.get<{ writers: GroupManagerData[] }>(`/v1/channels/instances/${instanceId}/writers`, { group_id: groupId }); return res.writers ?? []; }, [instanceId, http], ); - const addWriter = useCallback( + const addManager = useCallback( async (groupId: string, userId: string, displayName?: string, username?: string) => { if (!instanceId) return; await http.post(`/v1/channels/instances/${instanceId}/writers`, { @@ -75,7 +78,7 @@ export function useChannelDetail(instanceId: string | undefined) { [instanceId, http], ); - const removeWriter = useCallback( + const removeManager = useCallback( async (groupId: string, userId: string) => { if (!instanceId) return; await http.delete(`/v1/channels/instances/${instanceId}/writers/${userId}?group_id=${encodeURIComponent(groupId)}`); @@ -83,14 +86,27 @@ export function useChannelDetail(instanceId: string | undefined) { [instanceId, http], ); + const listContacts = useCallback( + async (search: string, channelType?: string): Promise => { + const params: Record = {}; + if (search) params.search = search; + if (channelType) params.channel_type = channelType; + params.limit = "20"; + const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params); + return res.contacts ?? []; + }, + [http], + ); + return { instance, loading, updateInstance, - listWriterGroups, - listWriters, - addWriter, - removeWriter, + listManagerGroups, + listManagers, + addManager, + removeManager, + listContacts, refresh: invalidate, }; } diff --git a/ui/web/src/pages/contacts/contacts-page.tsx b/ui/web/src/pages/contacts/contacts-page.tsx new file mode 100644 index 00000000..af14bc22 --- /dev/null +++ b/ui/web/src/pages/contacts/contacts-page.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Contact, RefreshCw, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { PageHeader } from "@/components/shared/page-header"; +import { EmptyState } from "@/components/shared/empty-state"; +import { Pagination } from "@/components/shared/pagination"; +import { TableSkeleton } from "@/components/shared/loading-skeleton"; +import { formatDate } from "@/lib/format"; +import { useMinLoading } from "@/hooks/use-min-loading"; +import { useDeferredLoading } from "@/hooks/use-deferred-loading"; +import { useContacts } from "./hooks/use-contacts"; + +const CHANNEL_TYPES = ["telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu"]; + +export function ContactsPage() { + const { t } = useTranslation("contacts"); + const { t: tc } = useTranslation("common"); + + const [search, setSearch] = useState(""); + const [appliedSearch, setAppliedSearch] = useState(""); + const [channelType, setChannelType] = useState(""); + const [peerKind, setPeerKind] = useState(""); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + const { contacts, total, loading, fetching, refresh } = useContacts({ + search: appliedSearch || undefined, + channelType: channelType || undefined, + peerKind: peerKind || undefined, + limit: pageSize, + offset: (page - 1) * pageSize, + }); + + const spinning = useMinLoading(fetching); + const showSkeleton = useDeferredLoading(loading && contacts.length === 0); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setAppliedSearch(search); + setPage(1); + }; + + const handleChannelChange = (val: string) => { + setChannelType(val === "all" ? "" : val); + setPage(1); + }; + + const handlePeerKindChange = (val: string) => { + setPeerKind(val === "all" ? "" : val); + setPage(1); + }; + + return ( +
+ + {tc("refresh")} + + } + /> + + {/* Filters */} +
+
+
+ + setSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + className="pl-9" + /> +
+ +
+ + + + +
+ + {/* Table */} +
+ {showSkeleton ? ( + + ) : contacts.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {contacts.map((c) => ( + + + + + + + + + ))} + +
{t("columns.name")}{t("columns.username")}{t("columns.senderId")}{t("columns.channelType")}{t("columns.peerKind")}{t("columns.lastSeen")}
+ {c.display_name || —} + + {c.username + ? @{c.username} + : — + } + {c.sender_id} + {c.channel_type} + + {c.peer_kind && ( + + {c.peer_kind === "direct" ? t("filters.direct") : t("filters.group")} + + )} + + {formatDate(c.last_seen_at)} +
+ + { setPageSize(s); setPage(1); }} + /> +
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/contacts/hooks/use-contacts.ts b/ui/web/src/pages/contacts/hooks/use-contacts.ts new file mode 100644 index 00000000..a1a66c10 --- /dev/null +++ b/ui/web/src/pages/contacts/hooks/use-contacts.ts @@ -0,0 +1,48 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ChannelContact } from "@/types/contact"; + +export type { ChannelContact }; + +export interface ContactFilters { + search?: string; + channelType?: string; + peerKind?: string; + limit?: number; + offset?: number; +} + +export function useContacts(filters: ContactFilters = {}) { + const http = useHttp(); + const queryClient = useQueryClient(); + + const queryKey = queryKeys.contacts.list({ ...filters }); + + const { data, isLoading: loading, isFetching } = useQuery({ + queryKey, + queryFn: async () => { + const params: Record = {}; + if (filters.search) params.search = filters.search; + if (filters.channelType) params.channel_type = filters.channelType; + if (filters.peerKind) params.peer_kind = filters.peerKind; + if (filters.limit) params.limit = String(filters.limit); + if (filters.offset !== undefined) params.offset = String(filters.offset); + + const res = await http.get<{ contacts: ChannelContact[]; total?: number }>("/v1/contacts", params); + return { contacts: res.contacts ?? [], total: res.total ?? 0 }; + }, + placeholderData: (prev) => prev, + }); + + const contacts = data?.contacts ?? []; + const total = data?.total ?? 0; + + const refresh = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.contacts.all }), + [queryClient], + ); + + return { contacts, total, loading, fetching: isFetching, refresh }; +} diff --git a/ui/web/src/routes.tsx b/ui/web/src/routes.tsx index ab01b614..43666d74 100644 --- a/ui/web/src/routes.tsx +++ b/ui/web/src/routes.tsx @@ -84,6 +84,9 @@ const PendingMessagesPage = lazy(() => const MemoryPage = lazy(() => import("@/pages/memory/memory-page").then((m) => ({ default: m.MemoryPage })), ); +const ContactsPage = lazy(() => + import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })), +); function PageLoader() { return ( @@ -141,6 +144,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/web/src/types/contact.ts b/ui/web/src/types/contact.ts new file mode 100644 index 00000000..2e08a9b7 --- /dev/null +++ b/ui/web/src/types/contact.ts @@ -0,0 +1,14 @@ +export interface ChannelContact { + id: string; + channel_type: string; + channel_instance?: string; + sender_id: string; + user_id?: string; + display_name?: string; + username?: string; + avatar_url?: string; + peer_kind?: string; + merged_id?: string; + first_seen_at: string; + last_seen_at: string; +}