feat(contacts): channel contacts table, auto-collector, contacts page & managers tab redesign

- 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
This commit is contained in:
viettranx committed 2026-03-10 14:29:01 +07:00
1 parent 1ddd2e547a
commit c8dc9917fe
32 files changed
+1204 -416

No files matched your search

+18 -2
View File
@@ -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())
+1 -1
View File
@@ -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 {
+64 -6
View File
@@ -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 {
+36
View File
@@ -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)
}
+51
View File
@@ -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
}
+154
View File
@@ -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
}
+1
View File
@@ -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
}
+1
View File
@@ -22,4 +22,5 @@ type Stores struct {
BuiltinTools BuiltinToolStore
PendingMessages PendingMessageStore
KnowledgeGraph KnowledgeGraphStore
Contacts ContactStore
}
+1 -1
View File
@@ -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
@@ -0,0 +1 @@
DROP TABLE IF EXISTS channel_contacts;
+22
View File
@@ -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);
+2
View File
@@ -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) {
<SidebarItem to={ROUTES.SESSIONS} icon={History} label={t("nav.sessions")} collapsed={collapsed} />
<SidebarItem to={ROUTES.PENDING_MESSAGES} icon={Inbox} label={t("nav.pendingMessages")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label={t("nav.channels")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CONTACTS} icon={Contact} label={t("nav.contacts")} collapsed={collapsed} />
<SidebarItem to={ROUTES.SKILLS} icon={Zap} label={t("nav.skills")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CRON} icon={Clock} label={t("nav.cron")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CUSTOM_TOOLS} icon={Wrench} label={t("nav.customTools")} collapsed={collapsed} />
+7 -1
View File
@@ -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],
+16 -14
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
+1
View File
@@ -13,6 +13,7 @@
"sessions": "Sessions",
"pendingMessages": "Pending Messages",
"channels": "Channels",
"contacts": "Contacts",
"skills": "Skills",
"cron": "Cron",
"customTools": "Custom Tools",
+16 -14
View File
@@ -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"
}
}
}
+24
View File
@@ -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"
}
}
+1
View File
@@ -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",
+16 -14
View File
@@ -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": "添加管理员失败"
}
}
}
+24
View File
@@ -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": "群组"
}
}
+1
View File
@@ -11,6 +11,7 @@
"approvals": "审批",
"builtinTools": "内置工具",
"channels": "Channel",
"contacts": "联系人",
"chat": "对话",
"config": "配置",
"cron": "定时任务",
+1
View File
@@ -19,6 +19,7 @@ export const ROUTES = {
USAGE: "/usage",
CHANNELS: "/channels",
CHANNEL_DETAIL: "/channels/:id",
CONTACTS: "/contacts",
APPROVALS: "/approvals",
NODES: "/nodes",
LOGS: "/logs",
+4
View File
@@ -30,6 +30,10 @@ export const queryKeys = {
list: (params: Record<string, unknown>) => ["channels", params] as const,
detail: (id: string) => ["channels", "detail", id] as const,
},
contacts: {
all: ["contacts"] as const,
list: (params: Record<string, unknown>) => ["contacts", params] as const,
},
skills: {
all: ["skills"] as const,
agentGrants: (agentId: string) => ["skills", "agent", agentId] as const,
@@ -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
<TabsTrigger value="credentials">{t("detail.tabs.credentials")}</TabsTrigger>
<TabsTrigger value="config">{t("detail.tabs.config")}</TabsTrigger>
{isTelegram && <TabsTrigger value="groups">{t("detail.tabs.groups")}</TabsTrigger>}
<TabsTrigger value="writers">{t("detail.tabs.writers")}</TabsTrigger>
<TabsTrigger value="managers">{t("detail.tabs.managers")}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
@@ -129,12 +130,13 @@ export function ChannelDetailPage({ instanceId, onBack }: ChannelDetailPageProps
</TabsContent>
)}
<TabsContent value="writers" className="mt-4">
<ChannelWritersTab
listWriterGroups={listWriterGroups}
listWriters={listWriters}
addWriter={addWriter}
removeWriter={removeWriter}
<TabsContent value="managers" className="mt-4">
<ChannelManagersTab
listManagerGroups={listManagerGroups}
listManagers={listManagers}
addManager={addManager}
removeManager={removeManager}
listContacts={listContacts}
/>
</TabsContent>
</Tabs>
@@ -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<GroupManagerGroupInfo[]>;
listManagers: (groupId: string) => Promise<GroupManagerData[]>;
addManager: (groupId: string, userId: string, displayName?: string, username?: string) => Promise<void>;
removeManager: (groupId: string, userId: string) => Promise<void>;
listContacts: (search: string, channelType?: string) => Promise<ChannelContact[]>;
}
/** Strips the "group:<channel>:" 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<ChannelContact[]>;
onAdd: (groupId: string, userId: string, displayName: string, username: string) => Promise<void>;
}
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<ComboboxOption[]>([]);
const contactCacheRef = useRef<Record<string, ChannelContact>>({});
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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<string, ChannelContact> = {};
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 (
<fieldset className="rounded-md border p-4 space-y-3">
<legend className="px-1 text-sm font-medium">{t("detail.managers.addForm.title")}</legend>
<p className="text-xs text-muted-foreground">{t("detail.managers.addForm.hint")}</p>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">{t("detail.managers.addForm.groupId")}</Label>
<Input
value={formGroupId}
onChange={(e) => setFormGroupId(e.target.value)}
placeholder={t("detail.managers.addForm.groupIdPlaceholder")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("detail.managers.addForm.userId")}</Label>
<Combobox
value={userId}
onChange={handleUserIdChange}
options={contactOptions}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("detail.managers.addForm.displayName")}</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("detail.managers.addForm.optional")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("detail.managers.addForm.username")}</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("detail.managers.addForm.usernameWithout")}
className="text-sm"
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={adding || !formGroupId.trim() || !userId.trim()}
size="sm"
className="gap-1"
>
<Plus className="h-3.5 w-3.5" />
{adding ? t("detail.managers.addForm.adding") : t("detail.managers.addForm.addManager")}
</Button>
</div>
</fieldset>
);
}
return (
<div className="flex items-end gap-2">
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.userId")}</Label>
<Combobox
value={userId}
onChange={handleUserIdChange}
options={contactOptions}
placeholder={t("detail.managers.addForm.userIdPlaceholder")}
className="h-8 text-sm"
/>
</div>
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.displayName")}</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("detail.managers.addForm.optional")}
className="h-8 text-sm"
/>
</div>
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.managers.addForm.username")}</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("detail.managers.addForm.optional")}
className="h-8 text-sm"
/>
</div>
<Button
size="sm"
className="h-8 gap-1 shrink-0"
onClick={handleSubmit}
disabled={adding || !userId.trim()}
>
<Plus className="h-3.5 w-3.5" />
{t("detail.managers.addForm.add")}
</Button>
{error && <p className="text-xs text-destructive mt-1">{error}</p>}
</div>
);
}
// --- Main tab component ---
export function ChannelManagersTab({
listManagerGroups,
listManagers,
addManager,
removeManager,
listContacts,
}: ChannelManagersTabProps) {
const { t } = useTranslation("channels");
const [groups, setGroups] = useState<GroupManagerGroupInfo[]>([]);
const [loadingGroups, setLoadingGroups] = useState(true);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [managersMap, setManagersMap] = useState<Record<string, GroupManagerData[]>>({});
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({});
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 (
<div className="max-w-3xl space-y-5">
<p className="text-sm text-muted-foreground">
{t("detail.managers.description")}
</p>
{/* Groups accordion */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{t("detail.managers.groups")}
{groups.length > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">({groups.length})</span>
)}
</h3>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshGroups} disabled={loadingGroups}>
<RefreshCw className={"h-3.5 w-3.5" + (loadingGroups ? " animate-spin" : "")} />
</Button>
</div>
{groups.length === 0 && !loadingGroups ? (
<EmptyState
icon={Users}
title={t("detail.managers.noManagerGroups")}
description={t("detail.managers.noManagerGroupsHint")}
/>
) : (
<div className="rounded-md border divide-y">
{groups.map((g) => {
const isOpen = expanded[g.group_id];
const groupManagers = managersMap[g.group_id] ?? [];
const isLoading = loadingMap[g.group_id];
return (
<div key={g.group_id}>
<button
type="button"
aria-expanded={isOpen}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
onClick={() => toggleGroup(g.group_id)}
>
{isOpen
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
}
<div className="min-w-0 flex-1">
<span className="font-mono text-sm">{shortGroupId(g.group_id)}</span>
<span className="ml-2 text-xs text-muted-foreground">{g.group_id}</span>
</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums">
{g.writer_count === 1
? t("detail.managers.managersCount", { count: g.writer_count })
: t("detail.managers.managersCountPlural", { count: g.writer_count })}
</span>
</button>
{isOpen && (
<div className="border-t bg-muted/10 px-4 py-3 space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground py-2">{t("detail.managers.loadingManagers")}</p>
) : groupManagers.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{t("detail.managers.noManagers")}</p>
) : (
<div className="rounded-md border bg-background">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.userId")}</th>
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.name")}</th>
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.managers.columns.username")}</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{groupManagers.map((w) => (
<tr key={w.user_id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-3 py-2 font-mono text-xs">{w.user_id}</td>
<td className="px-3 py-2">{w.display_name || <span className="text-muted-foreground">-</span>}</td>
<td className="px-3 py-2">{w.username ? <span className="text-muted-foreground">@{w.username}</span> : <span className="text-muted-foreground">-</span>}</td>
<td className="px-3 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveManager(g.group_id, w.user_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineAddForm
groupId={g.group_id}
listContacts={listContacts}
onAdd={handleAddManager}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Add to new group */}
<InlineAddForm
showGroupField
listContacts={listContacts}
onAdd={handleAddManager}
/>
</div>
);
}
@@ -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<GroupWriterGroupInfo[]>;
listWriters: (groupId: string) => Promise<GroupFileWriterData[]>;
addWriter: (groupId: string, userId: string, displayName?: string, username?: string) => Promise<void>;
removeWriter: (groupId: string, userId: string) => Promise<void>;
}
/** Strips the "group:<channel>:" 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<GroupWriterGroupInfo[]>([]);
const [loadingGroups, setLoadingGroups] = useState(true);
// Per-group expanded state & cached writers
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [writersMap, setWritersMap] = useState<Record<string, GroupFileWriterData[]>>({});
const [loadingMap, setLoadingMap] = useState<Record<string, boolean>>({});
// 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 (
<div className="max-w-3xl space-y-5">
<p className="text-sm text-muted-foreground">
{t("detail.writers.description")}
</p>
{/* Groups accordion */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">
{t("detail.writers.groups")}
{groups.length > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">({groups.length})</span>
)}
</h3>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshGroups} disabled={loadingGroups}>
<RefreshCw className={"h-3.5 w-3.5" + (loadingGroups ? " animate-spin" : "")} />
</Button>
</div>
{groups.length === 0 && !loadingGroups ? (
<EmptyState
icon={Users}
title={t("detail.writers.noWriterGroups")}
description={t("detail.writers.noWriterGroupsHint")}
/>
) : (
<div className="rounded-md border divide-y">
{groups.map((g) => {
const isOpen = expanded[g.group_id];
const groupWriters = writersMap[g.group_id] ?? [];
const isLoading = loadingMap[g.group_id];
return (
<div key={g.group_id}>
{/* Group header row */}
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
onClick={() => toggleGroup(g.group_id)}
>
{isOpen
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
}
<div className="min-w-0 flex-1">
<span className="font-mono text-sm">{shortGroupId(g.group_id)}</span>
<span className="ml-2 text-xs text-muted-foreground">{g.group_id}</span>
</div>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-xs font-medium tabular-nums">
{g.writer_count === 1
? t("detail.writers.writersCount", { count: g.writer_count })
: t("detail.writers.writersCountPlural", { count: g.writer_count })}
</span>
</button>
{/* Expanded: writers list */}
{isOpen && (
<div className="border-t bg-muted/10 px-4 py-3 space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground py-2">{t("detail.writers.loadingWriters")}</p>
) : groupWriters.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{t("detail.writers.noWriters")}</p>
) : (
<div className="rounded-md border bg-background">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.userId")}</th>
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.name")}</th>
<th className="px-3 py-2 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("detail.writers.columns.username")}</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{groupWriters.map((w) => (
<tr key={w.user_id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-3 py-2 font-mono text-xs">{w.user_id}</td>
<td className="px-3 py-2">{w.display_name || <span className="text-muted-foreground">-</span>}</td>
<td className="px-3 py-2">{w.username ? <span className="text-muted-foreground">@{w.username}</span> : <span className="text-muted-foreground">-</span>}</td>
<td className="px-3 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveWriter(g.group_id, w.user_id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Inline add form for this group */}
<div className="flex items-end gap-2">
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.userId")}</Label>
<Input
value={addUserId}
onChange={(e) => 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))}
/>
</div>
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.displayName")}</Label>
<Input
value={addDisplayName}
onChange={(e) => setAddDisplayName(e.target.value)}
placeholder={t("detail.writers.addForm.optional")}
className="h-8 text-sm"
/>
</div>
<div className="grid gap-1 flex-1">
<Label className="text-xs text-muted-foreground">{t("detail.writers.addForm.username")}</Label>
<Input
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
placeholder={t("detail.writers.addForm.optional")}
className="h-8 text-sm"
/>
</div>
<Button
size="sm"
className="h-8 gap-1 shrink-0"
onClick={() => handleAddWriter(g.group_id)}
disabled={adding || !addUserId.trim()}
>
<Plus className="h-3.5 w-3.5" />
{t("detail.writers.addForm.add")}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Add to new group */}
<fieldset className="rounded-md border p-4 space-y-3">
<legend className="px-1 text-sm font-medium">{t("detail.writers.addForm.title")}</legend>
<p className="text-xs text-muted-foreground">{t("detail.writers.addForm.hint")}</p>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="aw-group" className="text-xs">{t("detail.writers.addForm.groupId")}</Label>
<Input
id="aw-group"
value={addGroupId}
onChange={(e) => setAddGroupId(e.target.value)}
placeholder={t("detail.writers.addForm.groupIdPlaceholder")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aw-user" className="text-xs">{t("detail.writers.addForm.userId")}</Label>
<Input
id="aw-user"
value={addUserId}
onChange={(e) => setAddUserId(e.target.value)}
placeholder={t("detail.writers.addForm.userIdPlaceholder")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aw-display" className="text-xs">{t("detail.writers.addForm.displayName")}</Label>
<Input
id="aw-display"
value={addDisplayName}
onChange={(e) => setAddDisplayName(e.target.value)}
placeholder={t("detail.writers.addForm.optional")}
className="text-sm"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aw-username" className="text-xs">{t("detail.writers.addForm.username")}</Label>
<Input
id="aw-username"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
placeholder={t("detail.writers.addForm.usernameWithout")}
className="text-sm"
/>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<Button
onClick={() => handleAddWriter()}
disabled={adding || !addGroupId.trim() || !addUserId.trim()}
size="sm"
className="gap-1"
>
<Plus className="h-3.5 w-3.5" />
{adding ? t("detail.writers.addForm.adding") : t("detail.writers.addForm.addWriter")}
</Button>
</div>
</fieldset>
</div>
);
}
@@ -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<GroupWriterGroupInfo[]> => {
// Managers API (backend routes still use /writers paths)
const listManagerGroups = useCallback(
async (): Promise<GroupManagerGroupInfo[]> => {
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<GroupFileWriterData[]> => {
const listManagers = useCallback(
async (groupId: string): Promise<GroupManagerData[]> => {
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<ChannelContact[]> => {
const params: Record<string, string> = {};
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,
};
}
+184
View File
@@ -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 (
<div className="p-4 sm:p-6">
<PageHeader
title={t("title")}
description={t("description")}
actions={
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {tc("refresh")}
</Button>
}
/>
{/* Filters */}
<div className="mt-4 flex flex-wrap items-end gap-2">
<form onSubmit={handleSearchSubmit} className="flex gap-2 flex-1 min-w-[200px] max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="pl-9"
/>
</div>
<Button type="submit" variant="outline" size="sm">
{t("filter")}
</Button>
</form>
<Select value={channelType || "all"} onValueChange={handleChannelChange}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allChannels")}</SelectItem>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>{ct}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={peerKind || "all"} onValueChange={handlePeerKindChange}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<SelectItem value="direct">{t("filters.direct")}</SelectItem>
<SelectItem value="group">{t("filters.group")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={8} />
) : contacts.length === 0 ? (
<EmptyState
icon={Contact}
title={appliedSearch || channelType || peerKind ? t("noMatchTitle") : t("emptyTitle")}
description={appliedSearch || channelType || peerKind ? t("noMatchDescription") : t("emptyDescription")}
/>
) : (
<div className="rounded-md border overflow-x-auto">
<table className="w-full min-w-[700px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.name")}</th>
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.username")}</th>
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.senderId")}</th>
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.channelType")}</th>
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.peerKind")}</th>
<th className="px-3 py-2.5 text-left font-medium text-xs uppercase tracking-wide text-muted-foreground">{t("columns.lastSeen")}</th>
</tr>
</thead>
<tbody>
{contacts.map((c) => (
<tr key={c.id} className="border-b last:border-0 hover:bg-muted/20 transition-colors">
<td className="px-3 py-2.5">
{c.display_name || <span className="text-muted-foreground">—</span>}
</td>
<td className="px-3 py-2.5">
{c.username
? <span className="text-muted-foreground">@{c.username}</span>
: <span className="text-muted-foreground">—</span>
}
</td>
<td className="px-3 py-2.5 font-mono text-xs">{c.sender_id}</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className="text-[11px]">{c.channel_type}</Badge>
</td>
<td className="px-3 py-2.5">
{c.peer_kind && (
<Badge variant={c.peer_kind === "direct" ? "default" : "secondary"} className="text-[11px]">
{c.peer_kind === "direct" ? t("filters.direct") : t("filters.group")}
</Badge>
)}
</td>
<td className="px-3 py-2.5 text-muted-foreground text-xs">
{formatDate(c.last_seen_at)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
/>
</div>
)}
</div>
</div>
);
}
@@ -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<string, string> = {};
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 };
}
+4
View File
@@ -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() {
<Route path={ROUTES.USAGE} element={<UsagePage />} />
<Route path={ROUTES.CHANNELS} element={<ChannelsPage key="list" />} />
<Route path={ROUTES.CHANNEL_DETAIL} element={<ChannelsPage key="detail" />} />
<Route path={ROUTES.CONTACTS} element={<ContactsPage />} />
<Route path={ROUTES.APPROVALS} element={<ApprovalsPage />} />
<Route path={ROUTES.NODES} element={<NodesPage />} />
<Route path={ROUTES.LOGS} element={<LogsPage />} />
+14
View File
@@ -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;
}