diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go index 139f5cc8..d0336e84 100644 --- a/internal/http/knowledge_graph_handlers.go +++ b/internal/http/knowledge_graph_handlers.go @@ -140,8 +140,8 @@ func (h *KnowledgeGraphHandler) handleTraverse(w http.ResponseWriter, r *http.Re if body.MaxDepth <= 0 { body.MaxDepth = 2 } - if body.MaxDepth > 3 { - body.MaxDepth = 3 + if body.MaxDepth > 5 { + body.MaxDepth = 5 } results, err := h.store.Traverse(r.Context(), agentID, body.UserID, body.EntityID, body.MaxDepth) @@ -345,6 +345,9 @@ func (h *KnowledgeGraphHandler) handleGraph(w http.ResponseWriter, r *http.Reque if limit <= 0 { limit = 200 } + if limit > 500 { + limit = 500 + } entities, err := h.store.ListEntities(r.Context(), agentID, userID, store.EntityListOptions{Limit: limit}) if err != nil { diff --git a/internal/store/knowledge_graph_store.go b/internal/store/knowledge_graph_store.go index d7723acd..0e7f9c6a 100644 --- a/internal/store/knowledge_graph_store.go +++ b/internal/store/knowledge_graph_store.go @@ -51,6 +51,7 @@ type GraphStats struct { EntityCount int `json:"entity_count"` RelationCount int `json:"relation_count"` EntityTypes map[string]int `json:"entity_types"` + UserIDs []string `json:"user_ids,omitempty"` } // DedupCandidate represents a pair of entities that may be duplicates. diff --git a/internal/store/pg/knowledge_graph_relations.go b/internal/store/pg/knowledge_graph_relations.go index 506030ad..fac481cb 100644 --- a/internal/store/pg/knowledge_graph_relations.go +++ b/internal/store/pg/knowledge_graph_relations.go @@ -320,6 +320,24 @@ func (s *PGKnowledgeGraphStore) Stats(ctx context.Context, agentID, userID strin } stats.EntityTypes[t] = c } + + // Fetch distinct user IDs (only when not filtering by specific user) + if userID == "" { + uidRows, uidErr := s.db.QueryContext(ctx, + `SELECT DISTINCT user_id FROM kg_entities WHERE agent_id = $1`+tenantFilter+` AND user_id != '' ORDER BY user_id`, + append([]any{aid}, tcArgs...)..., + ) + if uidErr == nil { + defer uidRows.Close() + for uidRows.Next() { + var uid string + if uidRows.Scan(&uid) == nil && uid != "" { + stats.UserIDs = append(stats.UserIDs, uid) + } + } + } + } + return stats, nil } diff --git a/internal/tools/knowledge_graph.go b/internal/tools/knowledge_graph.go index aeb1a7b4..0d5ee5a5 100644 --- a/internal/tools/knowledge_graph.go +++ b/internal/tools/knowledge_graph.go @@ -50,7 +50,7 @@ func (t *KnowledgeGraphSearchTool) Parameters() map[string]any { }, "max_depth": map[string]any{ "type": "number", - "description": "Maximum traversal depth (default 2, max 3)", + "description": "Maximum traversal depth (default 2, max 5)", }, }, "required": []string{"query"}, @@ -76,7 +76,7 @@ func (t *KnowledgeGraphSearchTool) Execute(ctx context.Context, args map[string] entityID, _ := args["entity_id"].(string) maxDepth := 2 if md, ok := args["max_depth"].(float64); ok && md > 0 { - maxDepth = min(int(md), 3) + maxDepth = min(int(md), 5) } // Traversal mode: entity_id provided diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index 64b0875b..369c5de0 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -41,6 +41,9 @@ export function Combobox({ // Track whether user actively typed since last focus — when false, show all options const inputDirtyRef = React.useRef(false); const [inputDirty, setInputDirty] = React.useState(false); + // After selection, hold the selected value to suppress sync effects until user types again. + // Prevents label→UUID→label flash caused by options reloading after selection. + const selectedValueRef = React.useRef(null); const inputRef = React.useRef(null); const containerRef = React.useRef(null); const dropdownRef = React.useRef(null); @@ -48,6 +51,10 @@ export function Combobox({ // Sync search text when value changes externally — show label if available React.useEffect(() => { + // After handleSelect, skip sync while value is still the selected value. + // handleSelect already set the display text to the label. + if (selectedValueRef.current !== null && selectedValueRef.current === value) return; + selectedValueRef.current = null; const match = options.find((o) => o.value === value); setSearch(match?.label || value); }, [value, options]); @@ -162,6 +169,7 @@ export function Combobox({ }, [options, search]); const handleSelect = (val: string) => { + selectedValueRef.current = val; // suppress value-sync until user types again onChange(val); onSelect?.(val); const match = options.find((o) => o.value === val); @@ -173,6 +181,7 @@ export function Combobox({ const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value; + selectedValueRef.current = null; // user is typing — resume normal value sync setSearch(val); onChange(val); if (!inputDirtyRef.current) { diff --git a/ui/web/src/i18n/locales/en/memory.json b/ui/web/src/i18n/locales/en/memory.json index df0d44a4..6e599e38 100644 --- a/ui/web/src/i18n/locales/en/memory.json +++ b/ui/web/src/i18n/locales/en/memory.json @@ -143,7 +143,8 @@ "source": "Source:", "properties": "Properties:", "relations": "Relations", - "traverse": "Traverse (3-hop)", + "traverse": "Traverse", + "depthOption": "{{depth}}-hop", "tabs": { "table": "Table", "graph": "Graph" diff --git a/ui/web/src/i18n/locales/vi/memory.json b/ui/web/src/i18n/locales/vi/memory.json index 628ca51a..99000dc8 100644 --- a/ui/web/src/i18n/locales/vi/memory.json +++ b/ui/web/src/i18n/locales/vi/memory.json @@ -143,7 +143,8 @@ "source": "Nguồn:", "properties": "Thuộc tính:", "relations": "Quan hệ", - "traverse": "Duyệt (3 bước)", + "traverse": "Duyệt", + "depthOption": "{{depth}} bước", "tabs": { "table": "Bảng", "graph": "Đồ thị" diff --git a/ui/web/src/i18n/locales/zh/memory.json b/ui/web/src/i18n/locales/zh/memory.json index 54aaf1c0..87464690 100644 --- a/ui/web/src/i18n/locales/zh/memory.json +++ b/ui/web/src/i18n/locales/zh/memory.json @@ -143,7 +143,8 @@ "source": "来源:", "properties": "属性:", "relations": "关系", - "traverse": "遍历(3 跳)", + "traverse": "遍历", + "depthOption": "{{depth}} 跳", "tabs": { "table": "表格", "graph": "图谱" diff --git a/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx b/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx index 906bd06e..7cfe6c57 100644 --- a/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx +++ b/ui/web/src/pages/cli-credentials/cli-user-credentials-dialog.tsx @@ -52,6 +52,8 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser // Form state const [editEntry, setEditEntry] = useState(null); const [userId, setUserId] = useState(""); + // Separate search text from selected value (onChange fires on every keystroke) + const [userSearchText, setUserSearchText] = useState(""); const [env, setEnv] = useState>({}); const [saving, setSaving] = useState(false); const [deleting, setDeletingId] = useState(null); @@ -77,6 +79,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser setView("list"); setEditEntry(null); setUserId(""); + setUserSearchText(""); setEnv({}); loadList(); }, [open, loadList]); @@ -84,6 +87,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser const openAdd = () => { setEditEntry(null); setUserId(""); + setUserSearchText(""); setEnv({}); setView("form"); }; @@ -91,6 +95,7 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser const openEdit = async (entry: UserCredEntry) => { setEditEntry(entry); setUserId(entry.user_id); + setUserSearchText(entry.user_id); setEnv({}); setView("form"); // Load existing env for edit @@ -234,8 +239,9 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
{ setUserId(val); setUserSearchText(val); }} placeholder={t("userCredentials.userIdPlaceholder")} source="tenant_user" allowCustom diff --git a/ui/web/src/pages/knowledge-graph/knowledge-graph-page.tsx b/ui/web/src/pages/knowledge-graph/knowledge-graph-page.tsx index d375c5b6..0afa2d36 100644 --- a/ui/web/src/pages/knowledge-graph/knowledge-graph-page.tsx +++ b/ui/web/src/pages/knowledge-graph/knowledge-graph-page.tsx @@ -5,8 +5,9 @@ import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/shared/empty-state"; import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useEmbeddingStatus } from "@/hooks/use-embedding-status"; -import { useSessions } from "@/pages/sessions/hooks/use-sessions"; -import { parseSessionKey } from "@/lib/session-key"; +import { useContactResolver } from "@/hooks/use-contact-resolver"; +import { formatUserLabel } from "@/lib/format-user-label"; +import { useKGStats } from "@/pages/memory/hooks/use-knowledge-graph"; import { KGEntitiesTab } from "@/pages/memory/kg-entities-tab"; export function KnowledgeGraphPage() { @@ -17,29 +18,17 @@ export function KnowledgeGraphPage() { const [agentId, setAgentId] = useState(""); const [userIdFilter, setUserIdFilter] = useState(""); - // Resolve agent UUID → agent_key (sessions filter uses agent_key in session key pattern) - const selectedAgent = agents.find((a) => a.id === agentId); - const agentKey = selectedAgent?.agent_key ?? ""; + // Fetch KG stats for the agent — includes distinct user_ids from KG entities + const { stats } = useKGStats(agentId); + const userIds = stats?.user_ids ?? []; + const { resolve } = useContactResolver(userIds); - // Fetch sessions for selected agent to build scope picker (DM + group chats) - const { sessions } = useSessions({ agentFilter: agentKey || undefined, limit: 200 }); - - // Dedupe sessions by userID → scope options showing chat title / display name + // Build scope options from KG entity user IDs (more reliable than sessions) const scopeOptions = useMemo(() => { - const seen = new Map(); - for (const s of sessions) { - const uid = s.userID || parseSessionKey(s.key).scope; - if (!uid || seen.has(uid)) continue; - const meta = s.metadata; - const label = meta?.chat_title || meta?.display_name - || (meta?.username ? `@${meta.username}` : null) - || uid; - seen.set(uid, label); - } - return Array.from(seen.entries()) - .map(([value, label]) => ({ value, label })) + return userIds + .map((uid) => ({ value: uid, label: formatUserLabel(uid, resolve) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [sessions]); + }, [userIds, resolve]); return (
diff --git a/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx b/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx index cdb9fab0..81fd1af9 100644 --- a/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx +++ b/ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx @@ -57,6 +57,9 @@ export function MCPUserCredentialsDialog({ currentTenant?.role === "admin"; const [selectedUserId, setSelectedUserId] = useState(currentUserId); + // Separate search text from selected value — onChange fires on every keystroke, + // but we only want to load credentials when user actually selects from dropdown + const [userSearchText, setUserSearchText] = useState(""); const [status, setStatus] = useState(null); const [loadingStatus, setLoadingStatus] = useState(false); @@ -68,24 +71,34 @@ export function MCPUserCredentialsDialog({ const [env, setEnv] = useState>({}); - // Reset selected user when dialog opens + // Reset state when dialog opens useEffect(() => { - if (open) setSelectedUserId(currentUserId); + if (open) { + setSelectedUserId(currentUserId); + setUserSearchText(""); + setInitialLoad(true); + } }, [open, currentUserId]); + // Track whether this is the initial load (show full spinner) vs user switch (keep form visible) + const [initialLoad, setInitialLoad] = useState(true); + useEffect(() => { if (!open) return; setApiKey(""); setHeaders({}); setEnv({}); - setStatus(null); - setLoadingStatus(true); + // Only show full-page spinner on initial dialog open, not on user switch + if (initialLoad) { + setStatus(null); + setLoadingStatus(true); + } const targetUser = canManageUsers ? selectedUserId : undefined; onGetCredentials(server.id, targetUser) .then(setStatus) .catch(() => {}) - .finally(() => setLoadingStatus(false)); - }, [open, server.id, onGetCredentials, canManageUsers, selectedUserId]); + .finally(() => { setLoadingStatus(false); setInitialLoad(false); }); + }, [open, server.id, onGetCredentials, canManageUsers, selectedUserId]); // eslint-disable-line react-hooks/exhaustive-deps const handleSave = async () => { setSaving(true); @@ -132,7 +145,7 @@ export function MCPUserCredentialsDialog({ - {loadingStatus ? ( + {loadingStatus && initialLoad ? (
@@ -143,11 +156,15 @@ export function MCPUserCredentialsDialog({
{ setSelectedUserId(val); setUserSearchText(val); }} placeholder={t("userCredentials.selectUser")} source="tenant_user" /> + {selectedUserId && selectedUserId !== userSearchText && ( +

{selectedUserId}

+ )}

{t("userCredentials.mergeHint")}

)} diff --git a/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts index 3ce37f41..765c7dc1 100644 --- a/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts +++ b/ui/web/src/pages/memory/hooks/use-knowledge-graph.ts @@ -141,7 +141,7 @@ export function useKGGraph(agentId: string, userId?: string) { queryKey: queryKeys.kg.graph(agentId, userId), queryFn: async () => { if (!agentId) return { entities: [], relations: [] }; - const params: Record = { limit: "50" }; + const params: Record = { limit: "200" }; if (userId) params.user_id = userId; return http.get(`/v1/agents/${agentId}/kg/graph`, params); }, diff --git a/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx index 639cbdd6..2f8d74e4 100644 --- a/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx +++ b/ui/web/src/pages/memory/kg-entity-detail-dialog.tsx @@ -10,8 +10,11 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { GitFork } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { useKGTraversal } from "./hooks/use-knowledge-graph"; +import { useHttp } from "@/hooks/use-ws"; +import { useKGDetailStore } from "@/stores/use-kg-detail-store"; import { KGGraphView } from "./kg-graph-view"; +import { toast } from "@/stores/use-toast-store"; +import i18n from "@/i18n"; import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; interface KGEntityDetailDialogProps { @@ -24,15 +27,44 @@ interface KGEntityDetailDialogProps { export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getEntityWithRelations }: KGEntityDetailDialogProps) { const { t } = useTranslation("memory"); + const http = useHttp(); const [relations, setRelations] = useState([]); const [loadingRels, setLoadingRels] = useState(false); - const { results: traversalResults, traversing, traverse, reset: resetTraversal } = useKGTraversal(agentId); - // Reset state when entity changes to prevent stale data from previous entity + // Dedicated store — isolated from main graph query cache + const traversalResults = useKGDetailStore((s) => s.traversalResults); + const traversing = useKGDetailStore((s) => s.traversing); + const depth = useKGDetailStore((s) => s.depth); + const setDepth = useKGDetailStore((s) => s.setDepth); + const setTraversalResults = useKGDetailStore((s) => s.setTraversalResults); + const setTraversing = useKGDetailStore((s) => s.setTraversing); + const resetStore = useKGDetailStore((s) => s.reset); + + // Traverse using dedicated store (bypasses React Query cache) + const traverse = useCallback( + async (entityId: string, userId?: string, maxDepth?: number) => { + setTraversing(true); + try { + const res = await http.post( + `/v1/agents/${agentId}/kg/traverse`, + { entity_id: entityId, user_id: userId || "", max_depth: maxDepth || depth }, + ); + setTraversalResults(res ?? []); + } catch (err) { + toast.error(i18n.t("memory:toast.traversalFailed"), err instanceof Error ? err.message : ""); + setTraversalResults([]); + } finally { + setTraversing(false); + } + }, + [http, agentId, depth, setTraversing, setTraversalResults], + ); + + // Reset store when entity changes useEffect(() => { setRelations([]); - resetTraversal(); - }, [entity?.id, resetTraversal]); + resetStore(); + }, [entity?.id, resetStore]); const loadRelations = useCallback(async () => { if (!entity) return; @@ -55,13 +87,13 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE const handleTraverse = useCallback(() => { if (!entity) return; - traverse(entity.id, entity.user_id, 3); - }, [entity, traverse]); + traverse(entity.id, entity.user_id, depth); + }, [entity, traverse, depth]); - // Auto-traverse on open — always traverse since state is reset on entity change + // Auto-traverse on open useEffect(() => { if (open && entity && !traversing) { - traverse(entity.id, entity.user_id, 3); + traverse(entity.id, entity.user_id, depth); } }, [open, entity]); // eslint-disable-line react-hooks/exhaustive-deps @@ -139,10 +171,23 @@ export function KGEntityDetailDialog({ open, onOpenChange, agentId, entity, getE

{t("kg.entity.relations")}

- +
+ + +
diff --git a/ui/web/src/pages/memory/kg-graph-view.tsx b/ui/web/src/pages/memory/kg-graph-view.tsx index bf3df40d..a79f6bc0 100644 --- a/ui/web/src/pages/memory/kg-graph-view.tsx +++ b/ui/web/src/pages/memory/kg-graph-view.tsx @@ -19,7 +19,7 @@ import { useTranslation } from "react-i18next"; import { useUiStore } from "@/stores/use-ui-store"; import type { KGEntity, KGRelation } from "@/types/knowledge-graph"; -const GRAPH_LIMIT = 50; +const GRAPH_LIMIT = 200; // Dual-theme palette — separate dark/light values for readability on both backgrounds interface TypeColor { @@ -120,10 +120,10 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): const w = 600, h = 400; // Scale forces by node count — tighter for small graphs, spread for large const n = nodes.length; - const linkDist = n > 60 ? 200 : n > 30 ? 160 : 120; - const chargeMul = n > 60 ? -250 : n > 30 ? -180 : -120; - const centerPull = n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08; - const collideBase = n > 60 ? 50 : n > 30 ? 40 : 35; + const linkDist = n > 150 ? 280 : n > 100 ? 240 : n > 60 ? 200 : n > 30 ? 160 : 120; + const chargeMul = n > 150 ? -350 : n > 100 ? -300 : n > 60 ? -250 : n > 30 ? -180 : -120; + const centerPull = n > 150 ? 0.015 : n > 100 ? 0.02 : n > 60 ? 0.03 : n > 30 ? 0.05 : 0.08; + const collideBase = n > 150 ? 60 : n > 100 ? 55 : n > 60 ? 50 : n > 30 ? 40 : 35; const simulation = forceSimulation(simNodes) .force("link", forceLink(simLinks).id((d: any) => d.id).distance(linkDist).strength(0.5)) .force("charge", forceManyBody().strength((d: any) => chargeMul * (d.mass ?? 1))) @@ -133,7 +133,9 @@ function computeForceLayout(nodes: Node[], edges: Edge[], entities: KGEntity[]): .force("collide", forceCollide().radius((d: any) => collideBase + (d.mass ?? 1) * 3).strength(0.7)) .stop(); const ticks = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); - for (let i = 0; i < ticks; i++) simulation.tick(); + // Cap ticks for large graphs — diminishing returns past 200 iterations + const maxTicks = n > 100 ? 200 : ticks; + for (let i = 0; i < Math.min(ticks, maxTicks); i++) simulation.tick(); return nodes.map((n, i) => ({ ...n, position: { x: simNodes[i]!.x ?? 0, y: simNodes[i]!.y ?? 0 } })); } diff --git a/ui/web/src/stores/use-kg-detail-store.ts b/ui/web/src/stores/use-kg-detail-store.ts new file mode 100644 index 00000000..f271a401 --- /dev/null +++ b/ui/web/src/stores/use-kg-detail-store.ts @@ -0,0 +1,36 @@ +import { create } from "zustand"; +import type { KGEntity, KGRelation, KGTraversalResult } from "@/types/knowledge-graph"; + +/** Dedicated store for KG entity detail dialog — isolated from main graph data. */ +interface KGDetailState { + entity: KGEntity | null; + relations: KGRelation[]; + traversalResults: KGTraversalResult[]; + traversing: boolean; + depth: number; + + setEntity: (entity: KGEntity | null) => void; + setRelations: (relations: KGRelation[]) => void; + setTraversalResults: (results: KGTraversalResult[]) => void; + setTraversing: (v: boolean) => void; + setDepth: (d: number) => void; + reset: () => void; +} + +const initialState = { + entity: null as KGEntity | null, + relations: [] as KGRelation[], + traversalResults: [] as KGTraversalResult[], + traversing: false, + depth: 3, +}; + +export const useKGDetailStore = create((set) => ({ + ...initialState, + setEntity: (entity) => set({ entity }), + setRelations: (relations) => set({ relations }), + setTraversalResults: (traversalResults) => set({ traversalResults }), + setTraversing: (traversing) => set({ traversing }), + setDepth: (depth) => set({ depth }), + reset: () => set({ ...initialState }), +})); diff --git a/ui/web/src/types/knowledge-graph.ts b/ui/web/src/types/knowledge-graph.ts index e5d8cf36..19cfedc3 100644 --- a/ui/web/src/types/knowledge-graph.ts +++ b/ui/web/src/types/knowledge-graph.ts @@ -36,6 +36,7 @@ export interface KGStats { entity_count: number; relation_count: number; entity_types: Record; + user_ids?: string[]; } export interface KGDedupCandidate {