mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
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:
1 parent
929dcd20c2
commit
c2bd5349e5
16 files changed
+190
-60
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -143,7 +143,8 @@
|
||||
"source": "Source:",
|
||||
"properties": "Properties:",
|
||||
"relations": "Relations",
|
||||
"traverse": "Traverse (3-hop)",
|
||||
"traverse": "Traverse",
|
||||
"depthOption": "{{depth}}-hop",
|
||||
"tabs": {
|
||||
"table": "Table",
|
||||
"graph": "Graph"
|
||||
|
||||
@@ -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ị"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -36,6 +36,7 @@ export interface KGStats {
|
||||
entity_count: number;
|
||||
relation_count: number;
|
||||
entity_types: Record<string, number>;
|
||||
user_ids?: string[];
|
||||
}
|
||||
|
||||
export interface KGDedupCandidate {
|
||||
|
||||
Reference in new issue
Block a user