Merge branch 'ji'

This commit is contained in:
吉鑫升 committed 2026-06-11 19:59:58 +08:00
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>
)}