Merge branch 'ji'
This commit is contained in:
commit
82600a3404
25 files changed
+929
-279
No files matched your search
@@ -148,6 +148,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
} | null>(null);
|
||||
const needsDrawRef = useRef(true);
|
||||
const autoFitRef = useRef(false);
|
||||
// While the initial layout is still settling we keep the whole graph in
|
||||
// view every frame; any pan/zoom/drag from the user takes over.
|
||||
const interactedRef = useRef(false);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
// Latest props readable from the rAF loop / event handlers.
|
||||
@@ -235,7 +238,21 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
sim.alpha = previous.size === 0 ? 1 : 0.5;
|
||||
}
|
||||
simRef.current = sim;
|
||||
if (previous.size === 0) autoFitRef.current = false;
|
||||
if (previous.size === 0) {
|
||||
autoFitRef.current = false;
|
||||
interactedRef.current = false;
|
||||
// Pre-settle a fresh force layout synchronously (time-bounded) so the
|
||||
// first paint is already roughly laid out instead of an exploding
|
||||
// cloud that drifts out of the viewport.
|
||||
if (props.layoutType === "force") {
|
||||
const deadline = performance.now() + 120;
|
||||
let guard = 0;
|
||||
while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) {
|
||||
sim.tick();
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
requestDraw();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.nodes, props.edges, props.degrees]);
|
||||
@@ -258,6 +275,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
sim.reheat(0.9);
|
||||
}
|
||||
autoFitRef.current = false;
|
||||
interactedRef.current = false;
|
||||
requestDraw();
|
||||
}, [props.layoutType, requestDraw]);
|
||||
|
||||
@@ -295,6 +313,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(factor: number, cx?: number, cy?: number) => {
|
||||
interactedRef.current = true;
|
||||
const t = transformRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
const px = cx ?? w / 2;
|
||||
@@ -340,6 +359,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const node = nodeMapRef.current.get(id);
|
||||
const { w, h } = sizeRef.current;
|
||||
if (!node || !w || !h) return;
|
||||
interactedRef.current = true;
|
||||
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
|
||||
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
|
||||
},
|
||||
@@ -357,6 +377,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
seedPositions(sim.nodes);
|
||||
sim.alpha = 1;
|
||||
autoFitRef.current = false;
|
||||
interactedRef.current = false;
|
||||
}
|
||||
requestDraw();
|
||||
}, [fitView, requestDraw]);
|
||||
@@ -678,12 +699,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
if (sim && layoutRef.current === "force" && sim.tick()) {
|
||||
active = true;
|
||||
// Auto-fit once after the initial layout settles.
|
||||
if (!autoFitRef.current && sim.alpha < 0.3) {
|
||||
// Until the user pans/zooms/drags, keep the settling layout fully in
|
||||
// view every frame so nodes never drift out of the viewport.
|
||||
if (!interactedRef.current && sizeRef.current.w) {
|
||||
fitView(false);
|
||||
autoFitRef.current = true;
|
||||
} else if (!autoFitRef.current && sim.alpha < 0.3) {
|
||||
autoFitRef.current = true;
|
||||
fitView(true);
|
||||
}
|
||||
} else if (sim && !autoFitRef.current && sim.nodes.length) {
|
||||
} else if (sim && !autoFitRef.current && sim.nodes.length && sizeRef.current.w) {
|
||||
autoFitRef.current = true;
|
||||
fitView(false);
|
||||
}
|
||||
@@ -776,6 +801,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
interactedRef.current = true;
|
||||
const { sx, sy } = localPoint(e);
|
||||
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
||||
const node = hitTest(wx, wy);
|
||||
@@ -861,6 +887,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
if (!canvas) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
interactedRef.current = true;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
zoomBy(
|
||||
Math.exp(-e.deltaY * 0.0012),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Ban,
|
||||
Compass,
|
||||
Crosshair,
|
||||
EyeOff,
|
||||
@@ -56,6 +57,7 @@ interface GraphDetailPanelProps {
|
||||
onViewSource: (noteId: string) => void;
|
||||
onLocate: (id: string) => void;
|
||||
onHide: (id: string) => void;
|
||||
onBlacklist: (id: string) => void;
|
||||
onPickNeighbor: (id: string) => void;
|
||||
onExpandNode: (id: string) => void;
|
||||
onSetExploreRoot: (id: string) => void;
|
||||
@@ -213,6 +215,14 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
||||
>
|
||||
<EyeOff size={14} /> 隐藏节点
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
|
||||
onClick={() => props.onBlacklist(node.id)}
|
||||
>
|
||||
<Ban size={14} /> 加入黑名单
|
||||
</button>
|
||||
{props.explore ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Eraser, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { GraphBlacklistItem } from "@/core/knowledge/api";
|
||||
|
||||
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
||||
import {
|
||||
getEdgeInfo,
|
||||
@@ -19,16 +21,26 @@ interface GraphSidebarProps {
|
||||
stats: GraphStats;
|
||||
topNodes: Array<{ node: GraphNode; degree: number }>;
|
||||
hiddenCount: number;
|
||||
blacklistItems: GraphBlacklistItem[];
|
||||
blacklistPending: boolean;
|
||||
junkCandidateCount: number;
|
||||
onSearchChange: (keyword: string) => void;
|
||||
onSearchEnter: () => void;
|
||||
onToggleNodeKind: (kind: NodeKind) => void;
|
||||
onToggleEdgeLabel: (label: string) => void;
|
||||
onRestoreHidden: () => void;
|
||||
onRemoveBlacklist: (entryId: string) => void;
|
||||
onCleanJunk: () => void;
|
||||
onPickNode: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
|
||||
type SectionKey =
|
||||
| "nodeTypes"
|
||||
| "edgeTypes"
|
||||
| "graphStats"
|
||||
| "coreNodes"
|
||||
| "blacklist";
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
@@ -69,6 +81,7 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
edgeTypes: true,
|
||||
graphStats: false,
|
||||
coreNodes: false,
|
||||
blacklist: true,
|
||||
});
|
||||
|
||||
const toggleFilterSection = (key: SectionKey) => {
|
||||
@@ -230,6 +243,49 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
))}
|
||||
</FilterSection>
|
||||
) : null}
|
||||
|
||||
<FilterSection
|
||||
title="图谱黑名单"
|
||||
extra={`${props.blacklistItems.length} 个`}
|
||||
collapsed={collapsedSections.blacklist}
|
||||
onToggle={() => toggleFilterSection("blacklist")}
|
||||
>
|
||||
<div className="kg-search-hint">
|
||||
黑名单中的主题 / 节点不会出现在图谱中。可在节点详情里加入黑名单。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
disabled={props.blacklistPending || props.junkCandidateCount === 0}
|
||||
title="将单字符、纯数字符号、通用泛词等低价值主题批量加入黑名单"
|
||||
onClick={props.onCleanJunk}
|
||||
>
|
||||
<Eraser size={13} /> 清理低价值主题
|
||||
{props.junkCandidateCount > 0 ? `(${props.junkCandidateCount})` : ""}
|
||||
</button>
|
||||
{props.blacklistItems.length === 0 ? (
|
||||
<div className="kg-search-hint">暂无黑名单条目。</div>
|
||||
) : (
|
||||
props.blacklistItems.map((item) => (
|
||||
<div key={item.id} className="kg-check-row">
|
||||
<span className="kg-check-row-main">
|
||||
<span className="kg-check-label" title={item.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
disabled={props.blacklistPending}
|
||||
title="移出黑名单,重新显示该节点"
|
||||
onClick={() => props.onRemoveBlacklist(item.id)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</FilterSection>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent, AgentQuestion } from "@/core/agents";
|
||||
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -356,27 +356,40 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||
return;
|
||||
}
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
const nextPublished = editIsPrivate ? false : editPublished;
|
||||
const nextQuestions = editCommonQuestions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0);
|
||||
|
||||
// Only send the fields that actually changed: the backend merges partial
|
||||
// updates (model_fields_set), so e.g. a 常用问题 edit no longer rewrites
|
||||
// skills/SOUL and saving stays fast.
|
||||
const baselineQuestions = normalizeAgentQuestions(
|
||||
agentDetail?.common_questions ?? agent.common_questions,
|
||||
);
|
||||
const request: UpdateAgentRequest = {};
|
||||
if (trimmedName !== agent.name) request.name = trimmedName;
|
||||
if (editDescription !== (agent.description ?? "")) request.description = editDescription;
|
||||
if (editSoul !== (agentDetail?.soul ?? agent.soul ?? "")) request.soul = editSoul;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (editIsPrivate !== agent.is_private) request.is_private = editIsPrivate;
|
||||
if ((editSquareId || "") !== (agent.square_id ?? "")) request.square_id = editSquareId;
|
||||
if (JSON.stringify(selectedSkills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = selectedSkills;
|
||||
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
|
||||
request.common_questions = nextQuestions;
|
||||
|
||||
if (Object.keys(request).length === 0) {
|
||||
setEditOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: {
|
||||
name: trimmedName,
|
||||
description: editDescription,
|
||||
soul: editSoul,
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
square_id: editSquareId,
|
||||
skills: selectedSkills,
|
||||
common_questions: editCommonQuestions
|
||||
.map((q) => ({
|
||||
title: q.title.trim() || q.prompt.trim(),
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0),
|
||||
},
|
||||
});
|
||||
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||
toast.success("智能体已更新");
|
||||
setEditOpen(false);
|
||||
} catch (err) {
|
||||
@@ -908,7 +921,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
className="min-h-[40vh] resize-y font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
useUpdateAgent,
|
||||
type Agent,
|
||||
type AgentQuestion,
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
@@ -271,18 +272,26 @@ export function AgentDetailPanel({
|
||||
prompt: q.prompt.trim() || q.title.trim(),
|
||||
}))
|
||||
.filter((q) => q.prompt.length > 0);
|
||||
// Only send the fields that actually changed: the backend merges partial
|
||||
// updates (model_fields_set), so e.g. editing 常用问题 alone no longer
|
||||
// re-saves skills/SOUL/发布状态 and the save returns quickly.
|
||||
const request: UpdateAgentRequest = {};
|
||||
if (trimmedName !== agent.name) request.name = trimmedName;
|
||||
if (draft.description !== (agent.description ?? ""))
|
||||
request.description = draft.description;
|
||||
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
if (
|
||||
JSON.stringify(nextQuestions) !==
|
||||
JSON.stringify(normalizeAgentQuestions(agent.common_questions))
|
||||
)
|
||||
request.common_questions = nextQuestions;
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: {
|
||||
name: trimmedName,
|
||||
description: draft.description,
|
||||
soul: draft.soul,
|
||||
published: nextPublished,
|
||||
skills: draft.skills,
|
||||
common_questions: nextQuestions,
|
||||
},
|
||||
});
|
||||
if (Object.keys(request).length > 0) {
|
||||
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||
}
|
||||
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
||||
const wasFeatured = agent.featured_order != null;
|
||||
if (isAdmin && draft.featured !== wasFeatured) {
|
||||
@@ -568,10 +577,13 @@ export function AgentDetailPanel({
|
||||
setDraft({ ...draft, soul: e.currentTarget.value })
|
||||
}
|
||||
placeholder="输入 SOUL.md 内容"
|
||||
className="min-h-40 font-mono text-xs"
|
||||
className="min-h-[45vh] resize-y font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground max-h-60 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
// No inner max-height: the SOUL text flows at full length and the
|
||||
// panel body scrolls as a whole, instead of a small nested scroll
|
||||
// box on an otherwise empty screen.
|
||||
<p className="text-muted-foreground whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{soulPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -248,3 +248,38 @@ export interface KnowledgeGraph {
|
||||
export async function getKnowledgeGraph() {
|
||||
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
|
||||
}
|
||||
|
||||
// --- graph blacklist ---
|
||||
|
||||
export interface GraphBlacklistItem {
|
||||
id: string;
|
||||
label: string;
|
||||
created_by?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export async function getGraphBlacklist() {
|
||||
const res = await parseResponse<{ items: GraphBlacklistItem[] }>(
|
||||
await apiFetch(`${baseUrl()}/graph/blacklist`),
|
||||
);
|
||||
return res.items;
|
||||
}
|
||||
|
||||
/** Blacklist node labels (entity names / note titles); they disappear from the graph. */
|
||||
export async function addGraphBlacklist(labels: string[]) {
|
||||
return parseResponse<{ added: number; items: GraphBlacklistItem[] }>(
|
||||
await apiFetch(`${baseUrl()}/graph/blacklist`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ labels }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeGraphBlacklist(entryId: string) {
|
||||
return parseResponse<{ ok: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/graph/blacklist/${encodeURIComponent(entryId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addGraphBlacklist,
|
||||
createKnowledgeNote,
|
||||
deleteKnowledgeNote,
|
||||
exportKnowledgeGraph,
|
||||
fetchVaultPage,
|
||||
getGraphBlacklist,
|
||||
getKnowledgeGraph,
|
||||
getKnowledgeNote,
|
||||
ingestKnowledgeFile,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
listDuplicates,
|
||||
listKnowledgeNotes,
|
||||
mergeKnowledgeNotes,
|
||||
removeGraphBlacklist,
|
||||
resolveNoteWikilinks,
|
||||
searchKnowledge,
|
||||
updateKnowledgeNote,
|
||||
@@ -80,6 +83,32 @@ export function useKnowledgeGraph() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGraphBlacklist() {
|
||||
return useQuery({
|
||||
queryKey: [...ROOT_KEY, "graph-blacklist"],
|
||||
queryFn: getGraphBlacklist,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGraphBlacklistActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: [...ROOT_KEY, "graph"] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...ROOT_KEY, "graph-blacklist"],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
add: useMutation({ mutationFn: addGraphBlacklist, onSuccess: invalidate }),
|
||||
remove: useMutation({
|
||||
mutationFn: removeGraphBlacklist,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useKnowledgeActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getStoredAuth } from "../auth";
|
||||
import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
@@ -761,14 +761,86 @@ export function useThreads(
|
||||
offset += response.length;
|
||||
}
|
||||
|
||||
return threads.filter(
|
||||
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
|
||||
);
|
||||
// Safety net on top of the backend's system-thread exclusion: drop
|
||||
// machine-created threads (scheduled tasks, roundtable workers,
|
||||
// notebooks) in case an older backend still returns them.
|
||||
return threads.filter((t) => {
|
||||
const metadata = t.metadata as Record<string, unknown> | null;
|
||||
if (metadata?.system) return false;
|
||||
const threadType = metadata?.thread_type;
|
||||
return (
|
||||
threadType !== "notebook" &&
|
||||
threadType !== "scheduler" &&
|
||||
threadType !== "roundtable"
|
||||
);
|
||||
});
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ThreadsPageResult {
|
||||
threads: AgentThread[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side paginated conversation list for the chats page. Unlike
|
||||
* `useThreads` (which loads a bounded recent window for the sidebar), this
|
||||
* fetches exactly one page per request so a long history is never pulled in
|
||||
* one go. `search` matches thread titles on the backend; one extra row is
|
||||
* over-fetched only to detect whether a next page exists. The query key stays
|
||||
* under ["threads", "search"] so existing invalidations refresh pages too.
|
||||
*/
|
||||
export function useThreadsPage(options: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
const { page, pageSize, search, metadata } = options;
|
||||
const normalizedSearch = search?.trim() ?? "";
|
||||
return useQuery<ThreadsPageResult>({
|
||||
queryKey: [
|
||||
"threads",
|
||||
"search",
|
||||
"paged",
|
||||
page,
|
||||
pageSize,
|
||||
normalizedSearch,
|
||||
metadata ?? null,
|
||||
],
|
||||
queryFn: async () => {
|
||||
// Direct fetch instead of the LangGraph SDK: the SDK's threads.search
|
||||
// drops fields it doesn't know about, and `query` (title search) is a
|
||||
// gateway extension.
|
||||
const response = await apiFetch(
|
||||
`${getLangGraphBaseURL()}/threads/search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
limit: pageSize + 1,
|
||||
offset: (page - 1) * pageSize,
|
||||
metadata: metadata ?? {},
|
||||
query: normalizedSearch || undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load conversations");
|
||||
}
|
||||
const rows = (await response.json()) as AgentThread[];
|
||||
return {
|
||||
threads: rows.slice(0, pageSize),
|
||||
hasMore: rows.length > pageSize,
|
||||
};
|
||||
},
|
||||
placeholderData: (previous) => previous,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
|
||||
@@ -174,173 +174,176 @@ export default function AgentChatPage() {
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
{/* Root: fixed-height horizontal split. The left main column owns the
|
||||
chat (home / conversation) and the input; the right sidebar is a
|
||||
separate flex sibling, so nothing in the main column can ever bleed
|
||||
over it. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
{/* Left: main conversation column */}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* Header in normal flow, fixed height. */}
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
||||
preview) lives in the left flex cell; the agent detail panel is a
|
||||
separate flex sibling OUTSIDE ChatBox, so the artifact preview
|
||||
always opens between the chat and the detail panel — the detail
|
||||
panel stays rightmost. */}
|
||||
<div className="flex size-full min-h-0 overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<ChatBox threadId={threadId}>
|
||||
{/* Main conversation column */}
|
||||
<main className="flex size-full min-h-0 min-w-0 flex-col">
|
||||
{/* Header in normal flow, fixed height. */}
|
||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span
|
||||
className="max-w-[200px] truncate text-xs font-medium"
|
||||
title={agentDisplayName}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
{agentDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLandingState ? (
|
||||
/* Home: centered hero, suggested questions, and input.
|
||||
Everything lives inside the scrollable main column. No fixed,
|
||||
no absolute, no viewport centering, so the avatar can't touch
|
||||
the header and the input can't cover the sidebar. */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{commonQuestions.length > 0 && (
|
||||
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={isDemoMode}
|
||||
onClick={() =>
|
||||
handleSubmit({ text: question.prompt, files: [] })
|
||||
}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="block max-w-[220px] truncate">
|
||||
{desensitize(question.title)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PlusSquare /> {t.agents.newChat}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLandingState ? (
|
||||
/* Home: centered hero, suggested questions, and input.
|
||||
Everything lives inside the scrollable main column. No fixed,
|
||||
no absolute, no viewport centering, so the avatar can't touch
|
||||
the header and the input can't cover the sidebar. */
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
||||
<AgentWelcome
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
className="mb-7"
|
||||
/>
|
||||
<div className="w-full max-w-(--container-width-md)">
|
||||
{commonQuestions.length > 0 && (
|
||||
<div className="mb-2 flex max-h-20 flex-wrap items-start justify-start gap-2 overflow-y-auto px-1">
|
||||
{commonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={isDemoMode}
|
||||
onClick={() =>
|
||||
handleSubmit({ text: question.prompt, files: [] })
|
||||
}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"border-border/70 bg-background/85 hover:bg-accent hover:border-border max-w-full rounded-full border px-3 py-1.5 text-left text-sm leading-5 shadow-sm transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="block max-w-[220px] truncate">
|
||||
{desensitize(question.title)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread
|
||||
hideAdminRecommendedQuestions
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Conversation: messages scroll, input
|
||||
docked at the bottom of the main column only. */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
) : (
|
||||
/* Conversation: messages scroll, input
|
||||
docked at the bottom of the main column only. */
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={showFollowups ? 96 : 24}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<TodoList
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||
<TodoList
|
||||
className="bg-background/5 mb-2"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos ||
|
||||
thread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{isDemoMode && (
|
||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Right: agent detail panel. */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</ChatBox>
|
||||
</div>
|
||||
</ChatBox>
|
||||
|
||||
{/* Right: agent detail panel — always the rightmost column. */}
|
||||
<AgentDetailPanel
|
||||
agent={agent}
|
||||
agentName={agentDisplayName}
|
||||
disabled={thread.isLoading || isDemoMode}
|
||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
||||
/>
|
||||
</div>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useThreads,
|
||||
useThreadsPage,
|
||||
useToggleThreadFavorite,
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
@@ -42,10 +42,10 @@ const PAGE_SIZE = 20;
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -54,46 +54,50 @@ export default function ChatsPage() {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
}, [t.pages.chats, t.pages.appName]);
|
||||
|
||||
// Debounce the search box so each keystroke doesn't fire a backend query.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Reset to the first page whenever the filtered result set changes.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, onlyFavorites]);
|
||||
}, [debouncedSearch, onlyFavorites]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return (threads ?? []).filter((thread) => {
|
||||
if (
|
||||
thread.metadata?.thread_type === "scheduler" ||
|
||||
thread.metadata?.thread_type === "roundtable"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (onlyFavorites && !isThreadFavorite(thread)) {
|
||||
return false;
|
||||
}
|
||||
return titleOfThread(thread)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
});
|
||||
}, [threads, search, onlyFavorites]);
|
||||
// Conversations are paginated on the backend: each page is one bounded
|
||||
// query (title search + favorites filter included), so a long history is
|
||||
// never fetched in one go. Pinned conversations come from their own small
|
||||
// query and are surfaced in a dedicated section on the first page only,
|
||||
// and dropped from the paginated rows so they don't repeat on every page.
|
||||
const favoriteFilter = useMemo(
|
||||
() => (onlyFavorites ? { is_favorite: true } : {}),
|
||||
[onlyFavorites],
|
||||
);
|
||||
const pinnedFilter = useMemo(
|
||||
() => ({ ...favoriteFilter, is_pinned: true }),
|
||||
[favoriteFilter],
|
||||
);
|
||||
const { data: pageData } = useThreadsPage({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
search: debouncedSearch,
|
||||
metadata: favoriteFilter,
|
||||
});
|
||||
const { data: pinnedData } = useThreadsPage({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
search: debouncedSearch,
|
||||
metadata: pinnedFilter,
|
||||
});
|
||||
|
||||
// Pinned conversations are surfaced in their own section on the first page
|
||||
// only, and excluded from the paginated list (so they don't repeat on every
|
||||
// page as the user pages through history).
|
||||
const pinnedThreads = useMemo(
|
||||
() => filteredThreads.filter(isThreadPinned),
|
||||
[filteredThreads],
|
||||
);
|
||||
const restThreads = useMemo(
|
||||
() => filteredThreads.filter((t) => !isThreadPinned(t)),
|
||||
[filteredThreads],
|
||||
);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(restThreads.length / PAGE_SIZE));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedThreads = restThreads.slice(
|
||||
(currentPage - 1) * PAGE_SIZE,
|
||||
currentPage * PAGE_SIZE,
|
||||
const pinnedThreads = pinnedData?.threads ?? [];
|
||||
const pagedThreads = useMemo(
|
||||
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
||||
[pageData?.threads],
|
||||
);
|
||||
const currentPage = page;
|
||||
const hasMore = pageData?.hasMore ?? false;
|
||||
|
||||
const renderThread = (thread: AgentThread) => {
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -154,7 +158,7 @@ export default function ChatsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const isEmpty = filteredThreads.length === 0;
|
||||
const isEmpty = pagedThreads.length === 0 && pinnedThreads.length === 0;
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
@@ -215,7 +219,7 @@ export default function ChatsPage() {
|
||||
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
{(currentPage > 1 || hasMore) && (
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -227,15 +231,13 @@ export default function ChatsPage() {
|
||||
{t.chats.prevPage}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
{currentPage}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((p) => Math.min(totalPages, p + 1))
|
||||
}
|
||||
disabled={!hasMore}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t.chats.nextPage}
|
||||
<ChevronRight className="size-4" />
|
||||
|
||||
@@ -29,7 +29,59 @@ import {
|
||||
} from "@/components/knowledge-graph/graphUtils";
|
||||
import "@/components/knowledge-graph/graphStyles.css";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useKnowledgeGraph, useKnowledgeNote } from "@/core/knowledge/hooks";
|
||||
import {
|
||||
useGraphBlacklist,
|
||||
useGraphBlacklistActions,
|
||||
useKnowledgeGraph,
|
||||
useKnowledgeNote,
|
||||
} from "@/core/knowledge/hooks";
|
||||
|
||||
/** 全量图谱默认只渲染度数最高的核心节点,超出可手动切换显示全部。 */
|
||||
const FULL_GRAPH_NODE_LIMIT = 300;
|
||||
|
||||
/** 常见的低价值实体名(抽取噪声),用于"一键清理"候选。 */
|
||||
const GENERIC_ENTITY_LABELS = new Set([
|
||||
"用户",
|
||||
"助手",
|
||||
"系统",
|
||||
"问题",
|
||||
"回答",
|
||||
"内容",
|
||||
"信息",
|
||||
"数据",
|
||||
"功能",
|
||||
"方法",
|
||||
"结果",
|
||||
"示例",
|
||||
"例子",
|
||||
"总结",
|
||||
"文档",
|
||||
"文件",
|
||||
"其他",
|
||||
"未知",
|
||||
"对话",
|
||||
"聊天",
|
||||
"测试",
|
||||
"工具",
|
||||
"平台",
|
||||
"项目",
|
||||
"任务",
|
||||
"报告",
|
||||
"分析",
|
||||
"none",
|
||||
"null",
|
||||
"unknown",
|
||||
"test",
|
||||
"todo",
|
||||
]);
|
||||
|
||||
/** 单字符、纯数字/符号(日期等)或通用泛词视为低价值主题。 */
|
||||
function isLowValueLabel(raw: string): boolean {
|
||||
const label = raw.trim();
|
||||
if (label.length <= 1) return true;
|
||||
if (/^[\d\s\p{P}\p{S}]+$/u.test(label)) return true;
|
||||
return GENERIC_ENTITY_LABELS.has(label.toLowerCase());
|
||||
}
|
||||
|
||||
export default function KnowledgeGraphPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -39,6 +91,8 @@ export default function KnowledgeGraphPage() {
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useKnowledgeGraph();
|
||||
const { data: blacklistItems = [] } = useGraphBlacklist();
|
||||
const blacklistActions = useGraphBlacklistActions();
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
@@ -50,6 +104,7 @@ export default function KnowledgeGraphPage() {
|
||||
new Set(),
|
||||
);
|
||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
||||
const [showAllNodes, setShowAllNodes] = useState(false);
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
@@ -134,6 +189,23 @@ export default function KnowledgeGraphPage() {
|
||||
);
|
||||
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
||||
|
||||
// 节点过多时全量模式默认只渲染度数最高的核心节点,避免布局/渲染卡顿。
|
||||
const fullGraphTruncated =
|
||||
!showAllNodes && filteredNodes.length > FULL_GRAPH_NODE_LIMIT;
|
||||
const limitedNodes = useMemo(() => {
|
||||
if (!fullGraphTruncated) return filteredNodes;
|
||||
return [...filteredNodes]
|
||||
.sort((a, b) => (degrees.get(b.id) ?? 0) - (degrees.get(a.id) ?? 0))
|
||||
.slice(0, FULL_GRAPH_NODE_LIMIT);
|
||||
}, [filteredNodes, fullGraphTruncated, degrees]);
|
||||
const limitedEdges = useMemo(() => {
|
||||
if (!fullGraphTruncated) return filteredEdges;
|
||||
const visible = new Set(limitedNodes.map((n) => n.id));
|
||||
return filteredEdges.filter(
|
||||
(e) => visible.has(e.source) && visible.has(e.target),
|
||||
);
|
||||
}, [filteredEdges, fullGraphTruncated, limitedNodes]);
|
||||
|
||||
const resetExplore = useCallback(() => {
|
||||
setExploreRootId(null);
|
||||
setExpandedNodeIds(new Set());
|
||||
@@ -244,8 +316,8 @@ export default function KnowledgeGraphPage() {
|
||||
]);
|
||||
|
||||
const displayedGraph = useMemo(() => {
|
||||
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
|
||||
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
|
||||
if (graphMode === "full") return { nodes: limitedNodes, edges: limitedEdges };
|
||||
if (!exploreRootId) return { nodes: limitedNodes, edges: [] };
|
||||
return {
|
||||
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
||||
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
||||
@@ -254,8 +326,8 @@ export default function KnowledgeGraphPage() {
|
||||
allEdges,
|
||||
allNodes,
|
||||
exploreRootId,
|
||||
filteredEdges,
|
||||
filteredNodes,
|
||||
limitedEdges,
|
||||
limitedNodes,
|
||||
graphMode,
|
||||
visibleEdgeIds,
|
||||
visibleNodeIds,
|
||||
@@ -389,6 +461,72 @@ export default function KnowledgeGraphPage() {
|
||||
setHiddenNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
// ------------------------------------------------------------- blacklist
|
||||
const addToBlacklist = blacklistActions.add;
|
||||
const removeFromBlacklist = blacklistActions.remove;
|
||||
|
||||
const handleBlacklistNode = useCallback(
|
||||
(id: string) => {
|
||||
const node = nodeById.get(id);
|
||||
if (!node) return;
|
||||
const label = getNodeTitle(node);
|
||||
addToBlacklist.mutate([label], {
|
||||
onSuccess: () => toast.success(`已将「${label}」加入黑名单`),
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "加入黑名单失败"),
|
||||
});
|
||||
setSelectedNodeId((current) => (current === id ? null : current));
|
||||
if (selectedNodeId === id) setShowDetail(false);
|
||||
},
|
||||
[nodeById, addToBlacklist, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleRemoveBlacklist = useCallback(
|
||||
(entryId: string) => {
|
||||
removeFromBlacklist.mutate(entryId, {
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "移除黑名单失败"),
|
||||
});
|
||||
},
|
||||
[removeFromBlacklist],
|
||||
);
|
||||
|
||||
// 低价值主题候选:实体节点中的单字符 / 纯数字符号 / 通用泛词。
|
||||
const junkCandidates = useMemo(() => {
|
||||
const blocked = new Set(
|
||||
blacklistItems.map((item) => item.label.trim().toLowerCase()),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of allNodes) {
|
||||
if ((n.type || "").toLowerCase() !== "entity") continue;
|
||||
const label = getNodeTitle(n).trim();
|
||||
const key = label.toLowerCase();
|
||||
if (!label || seen.has(key) || blocked.has(key)) continue;
|
||||
if (!isLowValueLabel(label)) continue;
|
||||
seen.add(key);
|
||||
out.push(label);
|
||||
}
|
||||
return out;
|
||||
}, [allNodes, blacklistItems]);
|
||||
|
||||
const handleCleanJunk = useCallback(() => {
|
||||
if (junkCandidates.length === 0) {
|
||||
toast.info("没有发现明显的低价值主题");
|
||||
return;
|
||||
}
|
||||
const preview = junkCandidates.slice(0, 10).join("、");
|
||||
const confirmed = window.confirm(
|
||||
`将 ${junkCandidates.length} 个低价值主题加入黑名单?\n例如:${preview}${junkCandidates.length > 10 ? " 等" : ""}`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
addToBlacklist.mutate(junkCandidates, {
|
||||
onSuccess: (res) => toast.success(`已清理 ${res.added} 个低价值主题`),
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "清理失败"),
|
||||
});
|
||||
}, [junkCandidates, addToBlacklist]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetch().then((result) => {
|
||||
if (result.isError) {
|
||||
@@ -532,6 +670,25 @@ export default function KnowledgeGraphPage() {
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? (
|
||||
<div className="kg-floating-pill">
|
||||
{fullGraphTruncated
|
||||
? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}`
|
||||
: `已显示全部 ${filteredNodes.length} 个节点`}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-pill-button"
|
||||
title={
|
||||
fullGraphTruncated
|
||||
? "渲染全部节点(节点很多时可能卡顿)"
|
||||
: "只渲染度数最高的核心节点"
|
||||
}
|
||||
onClick={() => setShowAllNodes((v) => !v)}
|
||||
>
|
||||
{fullGraphTruncated ? "显示全部" : "仅核心节点"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="kg-floating-pill">
|
||||
<span className="kg-pill-label">布局</span>
|
||||
<button
|
||||
@@ -608,11 +765,16 @@ export default function KnowledgeGraphPage() {
|
||||
stats={stats}
|
||||
topNodes={topNodes}
|
||||
hiddenCount={hiddenNodeIds.size}
|
||||
blacklistItems={blacklistItems}
|
||||
blacklistPending={addToBlacklist.isPending || removeFromBlacklist.isPending}
|
||||
junkCandidateCount={junkCandidates.length}
|
||||
onSearchChange={setSearchKeyword}
|
||||
onSearchEnter={handleSearchEnter}
|
||||
onToggleNodeKind={handleToggleNodeKind}
|
||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||
onRestoreHidden={handleRestoreHidden}
|
||||
onRemoveBlacklist={handleRemoveBlacklist}
|
||||
onCleanJunk={handleCleanJunk}
|
||||
onPickNode={handlePickNode}
|
||||
onClose={() => setShowSidebar(false)}
|
||||
/>
|
||||
@@ -677,6 +839,7 @@ export default function KnowledgeGraphPage() {
|
||||
onViewSource={handleViewSource}
|
||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||
onHide={handleHideNode}
|
||||
onBlacklist={handleBlacklistNode}
|
||||
onPickNeighbor={handlePickNode}
|
||||
onExpandNode={expandOneHop}
|
||||
onSetExploreRoot={handleSetExploreRoot}
|
||||
|
||||
@@ -546,7 +546,7 @@ export default function NewAgentPage() {
|
||||
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
||||
value={soulInput}
|
||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||
className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
|
||||
className="min-h-[45vh] resize-y overflow-y-auto font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user