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 committed 2026-03-10 18:46:44 +07:00
1 parent d301c64dd0
commit 4fce73198d
5 files changed
+149 -15

No files matched your search

+6
View File
@@ -120,6 +120,12 @@ func (h *AgentsHandler) handleSetInstanceFile(w http.ResponseWriter, r *http.Req
return 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 { if err := h.agents.SetUserContextFile(r.Context(), id, instanceUserID, fileName, payload.Content); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
+1
View File
@@ -213,6 +213,7 @@ type AgentStore interface {
// User-agent profiles + instances // User-agent profiles + instances
GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error) 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) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) 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 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 --- // --- User Instances ---
func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) {
@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
import { Save, Check, AlertCircle, Users, FileText } from "lucide-react"; import { Save, Check, AlertCircle, Users, FileText, Search, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useContactResolver } from "@/hooks/use-contact-resolver"; import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances"; import { useAgentInstances, type UserInstance } from "../hooks/use-agent-instances";
import { useContactSearch } from "../hooks/use-contact-search";
interface AgentInstancesTabProps { interface AgentInstancesTabProps {
agentId: string; agentId: string;
@@ -60,27 +61,36 @@ export function AgentInstancesTab({ agentId }: AgentInstancesTabProps) {
const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]); const instanceUserIDs = useMemo(() => instances.map((i) => i.user_id), [instances]);
const { resolve } = useContactResolver(instanceUserIDs); 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) { if (loading) {
return <div className="py-8 text-center text-sm text-muted-foreground">{t("instances.loadingInstances")}</div>; 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 ( return (
<div className="flex gap-4" style={{ minHeight: 400 }}> <div className="flex gap-4" style={{ minHeight: 400 }}>
{/* Instance list */} {/* Instance list */}
<div className="w-64 shrink-0 space-y-1 overflow-y-auto rounded-md border p-2"> <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"> <ContactSearchBox
{instances.length} instance{instances.length !== 1 ? "s" : ""} existingIDs={existingIDs}
</div> 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) => ( {instances.map((inst) => (
<InstanceRow <InstanceRow
key={inst.user_id} 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 }) { 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 lastSeen = instance.last_seen_at ? formatRelative(instance.last_seen_at) : null;
const contact = resolve(instance.user_id); 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 };
}