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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user