feat(agents): contact search in instances tab with auto profile creation

Add searchable contact dropdown in instances sidebar to find channel
contacts and add them as agent instances. Backend EnsureUserProfile
creates user_agent_profiles row on demand when admin adds contacts.
This commit is contained in:
viettranx
2026-03-10 18:46:11 +07:00
parent d301c64dd0
commit 4fce73198d
5 changed files with 149 additions and 15 deletions
+6
View File
@@ -120,6 +120,12 @@ func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Req
return
}
// Ensure user profile exists (creates row if needed, e.g. admin adds contact manually).
if err := h.agents.EnsureUserProfile(r.Context(), id, instanceUserID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if err := h.agents.SetUserContextFile(r.Context(), id, instanceUserID, fileName, payload.Content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
+1
View File
@@ -213,6 +213,7 @@ type AgentStore interface {
// User-agent profiles + instances
GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error)
EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error
ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error
+11
View File
@@ -111,6 +111,17 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.
return isInserted, ws, nil
}
// EnsureUserProfile creates a minimal user_agent_profiles row if not exists.
// Used when admin manually adds a contact as an agent instance via the UI.
func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (agent_id, user_id) DO NOTHING
`, agentID, userID)
return err
}
// --- User Instances ---
func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from "react";
import { Save, Check, AlertCircle, Users, FileText } from "lucide-react";
import { useState, useEffect, useMemo, useRef } from "react";
import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances";
import { useContactSearch } from "../hooks/use-contact-search";
interface AgentInstancesTabProps {
agentId: string;
@@ -60,27 +61,36 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]);
const { resolve } = useContactResolver(instanceUserIDs);
// Existing instance user_ids for deduplication
const existingIDs = useMemo(() => new Set(instances.map((i) => i.user_id)), [instances]);
const handleContactSelect = (senderID: string) => {
setSelected(senderID);
};
if (loading) {
return <div className="py-8 text-center text-sm text-muted-foreground">{t("instances.loadingInstances")}</div>;
}
if (instances.length === 0) {
return (
<div className="flex flex-col items-center gap-2 py-12 text-center">
<Users className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">{t("instances.noInstances")}</p>
<p className="text-xs text-muted-foreground/70">{t("instances.noInstancesDesc")}</p>
</div>
);
}
return (
<div className="flex gap-4" style={{ minHeight: 400 }}>
{/* Instance list */}
<div className="w-64 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2">
<div className="px-2 pb-2 text-xs font-medium text-muted-foreground">
{instances.length} instance{instances.length !== 1 ? "s" : ""}
</div>
<ContactSearchBox
existingIDs={existingIDs}
onSelect={handleContactSelect}
/>
{instances.length > 0 && (
<div className="px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">
{instances.length} instance{instances.length !== 1 ? "s" : ""}
</div>
)}
{instances.length === 0 && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Users className="h-6 w-6 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">{t("instances.noInstances")}</p>
</div>
)}
{instances.map((inst) => (
<InstanceRow
key={inst.user_id}
@@ -140,6 +150,78 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
);
}
/** Inline contact search dropdown for adding new instances. */
function ContactSearchBox({ existingIDs, onSelect }: { existingIDs: Set<string>; onSelect: (id: string) => void }) {
const { t } = useTranslation("agents");
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const { contacts } = useContactSearch(search);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out contacts already in instances
const filtered = contacts.filter((c) => !existingIDs.has(c.sender_id));
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={containerRef} className="relative px-1 pb-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onFocus={() => search.length >= 2 && setOpen(true)}
placeholder={t("instances.searchContacts")}
className="h-8 w-full rounded-md border bg-transparent pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{open && search.length >= 2 && filtered.length > 0 && (
<div className="absolute left-1 right-1 z-50 mt-1 max-h-48 overflow-y-auto rounded-md border bg-popover p-1 shadow-md">
{filtered.map((c) => (
<button
key={c.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onSelect(c.sender_id);
setSearch("");
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
>
<UserPlus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{c.display_name || c.sender_id}
</div>
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{c.username && <span>@{c.username}</span>}
<Badge variant="outline" className="text-[9px] px-1 py-0">{c.channel_type}</Badge>
</div>
</div>
</button>
))}
</div>
)}
{open && search.length >= 2 && filtered.length === 0 && contacts.length === 0 && (
<div className="absolute left-1 right-1 z-50 mt-1 rounded-md border bg-popover p-3 text-center text-xs text-muted-foreground shadow-md">
{t("instances.noContactsFound")}
</div>
)}
</div>
);
}
function InstanceRow({ instance, isSelected, onClick, resolve }: { instance: UserInstance; isSelected: boolean; onClick: () => void; resolve: (id: string) => import("@/types/contact").ChannelContact | null }) {
const lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null;
const contact = resolve(instance.user_id);
@@ -0,0 +1,34 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type { ChannelContact } from "@/types/contact";
/**
* Searches contacts by name/username/sender_id with server-side filtering.
* Returns results as ComboboxOption-compatible items.
*/
export function useContactSearch(search: string) {
const http = useHttp();
const [debouncedSearch, setDebouncedSearch] = useState("");
// Simple debounce via timeout
useMemo(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const { data, isLoading } = useQuery({
queryKey: queryKeys.contacts.list({ search: debouncedSearch, limit: 20 }),
queryFn: async () => {
const params: Record<string, string> = { limit: "20" };
if (debouncedSearch) params.search = debouncedSearch;
const res = await http.get<{ contacts: ChannelContact[] }>("/v1/contacts", params);
return res.contacts ?? [];
},
enabled: debouncedSearch.length >= 2,
staleTime: 30_000,
});
return { contacts: data ?? [], loading: isLoading };
}