fix(ui,kg): fix credential user picker, KG filters, graph limits, and traversal depth

- Fix MCP/CLI credential dialogs: separate search text from selected user ID
  to prevent keystroke-as-selection bug (use onSelect pattern)
- Fix Combobox: suppress value-sync after selection to prevent label flicker
- Fix MCP dialog: prevent full-page spinner flash when switching users
- Fix KG page filter: replace session-based scope with stats-based user_ids
  from KG entities (more reliable user list)
- Increase KG graph visualization limit from 50 to 200 nodes with optimized
  D3 force layout breakpoints for 100+/150+ nodes
- Cap graph API server-side limit at 500 to prevent unbounded queries
- Increase KG traversal max_depth from 3 to 5 (HTTP API + agent tool)
- Add dedicated Zustand store for KG detail dialog (isolated from main graph)
- Add depth selector UI (2-5 hops) in entity detail dialog
This commit is contained in:
viettranx committed 2026-04-03 16:45:00 +07:00
1 parent 929dcd20c2
commit c2bd5349e5
16 files changed
+190 -60

No files matched your search

+5 -2
View File
@@ -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 {
+1
View File
@@ -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.
@@ -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
}
+2 -2
View File
@@ -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
+9
View File
@@ -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<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const dropdownRef = React.useRef<HTMLDivElement>(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<HTMLInputElement>) => {
const val = e.target.value;
selectedValueRef.current = null; // user is typing — resume normal value sync
setSearch(val);
onChange(val);
if (!inputDirtyRef.current) {
+2 -1
View File
@@ -143,7 +143,8 @@
"source": "Source:",
"properties": "Properties:",
"relations": "Relations",
"traverse": "Traverse (3-hop)",
"traverse": "Traverse",
"depthOption": "{{depth}}-hop",
"tabs": {
"table": "Table",
"graph": "Graph"
+2 -1
View File
@@ -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ị"
+2 -1
View File
@@ -143,7 +143,8 @@
"source": "来源:",
"properties": "属性:",
"relations": "关系",
"traverse": "遍历(3 跳)",
"traverse": "遍历",
"depthOption": "{{depth}} 跳",
"tabs": {
"table": "表格",
"graph": "图谱"
@@ -52,6 +52,8 @@ export function CLIUserCredentialsDialog({ open, onOpenChange, binary }: CLIUser
// Form state
const [editEntry, setEditEntry] = useState<UserCredEntry | null>(null);
const [userId, setUserId] = useState("");
// Separate search text from selected value (onChange fires on every keystroke)
const [userSearchText, setUserSearchText] = useState("");
const [env, setEnv] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [deleting, setDeletingId] = useState<string | null>(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
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.userId")}</Label>
<UserPickerCombobox
value={userId}
onChange={setUserId}
value={userSearchText}
onChange={setUserSearchText}
onSelect={(val) => { setUserId(val); setUserSearchText(val); }}
placeholder={t("userCredentials.userIdPlaceholder")}
source="tenant_user"
allowCustom
@@ -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<string, string>();
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 (
<div className="flex h-full flex-col p-4 sm:p-6">
@@ -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<MCPUserCredentialStatus | null>(null);
const [loadingStatus, setLoadingStatus] = useState(false);
@@ -68,24 +71,34 @@ export function MCPUserCredentialsDialog({
const [env, setEnv] = useState<Record<string, string>>({});
// 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({
</DialogDescription>
</DialogHeader>
{loadingStatus ? (
{loadingStatus && initialLoad ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
@@ -143,11 +156,15 @@ export function MCPUserCredentialsDialog({
<div className="flex flex-col gap-1.5">
<Label>{t("userCredentials.selectUser")}</Label>
<UserPickerCombobox
value={selectedUserId}
onChange={setSelectedUserId}
value={userSearchText}
onChange={setUserSearchText}
onSelect={(val) => { setSelectedUserId(val); setUserSearchText(val); }}
placeholder={t("userCredentials.selectUser")}
source="tenant_user"
/>
{selectedUserId && selectedUserId !== userSearchText && (
<p className="text-xs text-muted-foreground font-mono">{selectedUserId}</p>
)}
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded-md px-2.5 py-1.5 border border-amber-200 dark:border-amber-800">{t("userCredentials.mergeHint")}</p>
</div>
)}
@@ -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<string, string> = { limit: "50" };
const params: Record<string, string> = { limit: "200" };
if (userId) params.user_id = userId;
return http.get<KGGraphData>(`/v1/agents/${agentId}/kg/graph`, params);
},
@@ -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<KGRelation[]>([]);
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<import("@/types/knowledge-graph").KGTraversalResult[]>(
`/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
<div>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">{t("kg.entity.relations")}</h4>
<Button variant="outline" size="sm" onClick={handleTraverse} disabled={traversing} className="gap-1">
<GitFork className="h-3.5 w-3.5" />
{traversing ? t("kg.entity.traversing") : t("kg.entity.traverse")}
</Button>
<div className="flex items-center gap-2">
<select
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
className="h-8 rounded-md border bg-background px-2 text-base md:text-sm"
>
{[2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{t("kg.entity.depthOption", { depth: d })}
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={handleTraverse} disabled={traversing} className="gap-1">
<GitFork className="h-3.5 w-3.5" />
{traversing ? t("kg.entity.traversing") : t("kg.entity.traverse")}
</Button>
</div>
</div>
<Tabs defaultValue="table">
+8 -6
View File
@@ -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 } }));
}
+36
View File
@@ -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<KGDetailState>((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 }),
}));
+1
View File
@@ -36,6 +36,7 @@ export interface KGStats {
entity_count: number;
relation_count: number;
entity_types: Record<string, number>;
user_ids?: string[];
}
export interface KGDedupCandidate {