mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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:
1 parent
d301c64dd0
commit
4fce73198d
5 files changed
+149
-15
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user