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);
|
} | null>(null);
|
||||||
const needsDrawRef = useRef(true);
|
const needsDrawRef = useRef(true);
|
||||||
const autoFitRef = useRef(false);
|
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);
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
// Latest props readable from the rAF loop / event handlers.
|
// 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;
|
sim.alpha = previous.size === 0 ? 1 : 0.5;
|
||||||
}
|
}
|
||||||
simRef.current = sim;
|
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();
|
requestDraw();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [props.nodes, props.edges, props.degrees]);
|
}, [props.nodes, props.edges, props.degrees]);
|
||||||
@@ -258,6 +275,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
sim.reheat(0.9);
|
sim.reheat(0.9);
|
||||||
}
|
}
|
||||||
autoFitRef.current = false;
|
autoFitRef.current = false;
|
||||||
|
interactedRef.current = false;
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}, [props.layoutType, requestDraw]);
|
}, [props.layoutType, requestDraw]);
|
||||||
|
|
||||||
@@ -295,6 +313,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
const zoomBy = useCallback(
|
const zoomBy = useCallback(
|
||||||
(factor: number, cx?: number, cy?: number) => {
|
(factor: number, cx?: number, cy?: number) => {
|
||||||
|
interactedRef.current = true;
|
||||||
const t = transformRef.current;
|
const t = transformRef.current;
|
||||||
const { w, h } = sizeRef.current;
|
const { w, h } = sizeRef.current;
|
||||||
const px = cx ?? w / 2;
|
const px = cx ?? w / 2;
|
||||||
@@ -340,6 +359,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
const node = nodeMapRef.current.get(id);
|
const node = nodeMapRef.current.get(id);
|
||||||
const { w, h } = sizeRef.current;
|
const { w, h } = sizeRef.current;
|
||||||
if (!node || !w || !h) return;
|
if (!node || !w || !h) return;
|
||||||
|
interactedRef.current = true;
|
||||||
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
|
const k = clampZoom(Math.max(transformRef.current.k, 1.4));
|
||||||
animateTo({ x: w / 2 - node.x * k, y: h / 2 - node.y * k, k });
|
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);
|
seedPositions(sim.nodes);
|
||||||
sim.alpha = 1;
|
sim.alpha = 1;
|
||||||
autoFitRef.current = false;
|
autoFitRef.current = false;
|
||||||
|
interactedRef.current = false;
|
||||||
}
|
}
|
||||||
requestDraw();
|
requestDraw();
|
||||||
}, [fitView, requestDraw]);
|
}, [fitView, requestDraw]);
|
||||||
@@ -678,12 +699,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
if (sim && layoutRef.current === "force" && sim.tick()) {
|
if (sim && layoutRef.current === "force" && sim.tick()) {
|
||||||
active = true;
|
active = true;
|
||||||
// Auto-fit once after the initial layout settles.
|
// Until the user pans/zooms/drags, keep the settling layout fully in
|
||||||
if (!autoFitRef.current && sim.alpha < 0.3) {
|
// 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;
|
autoFitRef.current = true;
|
||||||
fitView(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;
|
autoFitRef.current = true;
|
||||||
fitView(false);
|
fitView(false);
|
||||||
}
|
}
|
||||||
@@ -776,6 +801,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
|
|
||||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
|
interactedRef.current = true;
|
||||||
const { sx, sy } = localPoint(e);
|
const { sx, sy } = localPoint(e);
|
||||||
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
||||||
const node = hitTest(wx, wy);
|
const node = hitTest(wx, wy);
|
||||||
@@ -861,6 +887,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
|||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
interactedRef.current = true;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
zoomBy(
|
zoomBy(
|
||||||
Math.exp(-e.deltaY * 0.0012),
|
Math.exp(-e.deltaY * 0.0012),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
Compass,
|
Compass,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -56,6 +57,7 @@ interface GraphDetailPanelProps {
|
|||||||
onViewSource: (noteId: string) => void;
|
onViewSource: (noteId: string) => void;
|
||||||
onLocate: (id: string) => void;
|
onLocate: (id: string) => void;
|
||||||
onHide: (id: string) => void;
|
onHide: (id: string) => void;
|
||||||
|
onBlacklist: (id: string) => void;
|
||||||
onPickNeighbor: (id: string) => void;
|
onPickNeighbor: (id: string) => void;
|
||||||
onExpandNode: (id: string) => void;
|
onExpandNode: (id: string) => void;
|
||||||
onSetExploreRoot: (id: string) => void;
|
onSetExploreRoot: (id: string) => void;
|
||||||
@@ -213,6 +215,14 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
|||||||
>
|
>
|
||||||
<EyeOff size={14} /> 隐藏节点
|
<EyeOff size={14} /> 隐藏节点
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kg-icon-button"
|
||||||
|
title="加入黑名单后该节点将不再出现在图谱中,可在左侧「图谱黑名单」中移除"
|
||||||
|
onClick={() => props.onBlacklist(node.id)}
|
||||||
|
>
|
||||||
|
<Ban size={14} /> 加入黑名单
|
||||||
|
</button>
|
||||||
{props.explore ? (
|
{props.explore ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<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 { useState } from "react";
|
||||||
|
|
||||||
|
import type { GraphBlacklistItem } from "@/core/knowledge/api";
|
||||||
|
|
||||||
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
||||||
import {
|
import {
|
||||||
getEdgeInfo,
|
getEdgeInfo,
|
||||||
@@ -19,16 +21,26 @@ interface GraphSidebarProps {
|
|||||||
stats: GraphStats;
|
stats: GraphStats;
|
||||||
topNodes: Array<{ node: GraphNode; degree: number }>;
|
topNodes: Array<{ node: GraphNode; degree: number }>;
|
||||||
hiddenCount: number;
|
hiddenCount: number;
|
||||||
|
blacklistItems: GraphBlacklistItem[];
|
||||||
|
blacklistPending: boolean;
|
||||||
|
junkCandidateCount: number;
|
||||||
onSearchChange: (keyword: string) => void;
|
onSearchChange: (keyword: string) => void;
|
||||||
onSearchEnter: () => void;
|
onSearchEnter: () => void;
|
||||||
onToggleNodeKind: (kind: NodeKind) => void;
|
onToggleNodeKind: (kind: NodeKind) => void;
|
||||||
onToggleEdgeLabel: (label: string) => void;
|
onToggleEdgeLabel: (label: string) => void;
|
||||||
onRestoreHidden: () => void;
|
onRestoreHidden: () => void;
|
||||||
|
onRemoveBlacklist: (entryId: string) => void;
|
||||||
|
onCleanJunk: () => void;
|
||||||
onPickNode: (id: string) => void;
|
onPickNode: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
|
type SectionKey =
|
||||||
|
| "nodeTypes"
|
||||||
|
| "edgeTypes"
|
||||||
|
| "graphStats"
|
||||||
|
| "coreNodes"
|
||||||
|
| "blacklist";
|
||||||
|
|
||||||
interface FilterSectionProps {
|
interface FilterSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -69,6 +81,7 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
edgeTypes: true,
|
edgeTypes: true,
|
||||||
graphStats: false,
|
graphStats: false,
|
||||||
coreNodes: false,
|
coreNodes: false,
|
||||||
|
blacklist: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleFilterSection = (key: SectionKey) => {
|
const toggleFilterSection = (key: SectionKey) => {
|
||||||
@@ -230,6 +243,49 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
|||||||
))}
|
))}
|
||||||
</FilterSection>
|
</FilterSection>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import {
|
|||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
} from "@/core/agents";
|
} from "@/core/agents";
|
||||||
import { getAgent } from "@/core/agents/api";
|
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 { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
@@ -356,27 +356,40 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||||
return;
|
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 {
|
try {
|
||||||
await updateAgent.mutateAsync({
|
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||||
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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success("智能体已更新");
|
toast.success("智能体已更新");
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -908,7 +921,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
|||||||
? "正在加载 SOUL.md..."
|
? "正在加载 SOUL.md..."
|
||||||
: "输入 SOUL.md 内容"
|
: "输入 SOUL.md 内容"
|
||||||
}
|
}
|
||||||
className="min-h-40 font-mono text-xs"
|
className="min-h-[40vh] resize-y font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
type Agent,
|
type Agent,
|
||||||
type AgentQuestion,
|
type AgentQuestion,
|
||||||
|
type UpdateAgentRequest,
|
||||||
} from "@/core/agents";
|
} from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
@@ -271,18 +272,26 @@ export function AgentDetailPanel({
|
|||||||
prompt: q.prompt.trim() || q.title.trim(),
|
prompt: q.prompt.trim() || q.title.trim(),
|
||||||
}))
|
}))
|
||||||
.filter((q) => q.prompt.length > 0);
|
.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 {
|
try {
|
||||||
await updateAgent.mutateAsync({
|
if (Object.keys(request).length > 0) {
|
||||||
id: agent.id,
|
await updateAgent.mutateAsync({ id: agent.id, request });
|
||||||
request: {
|
}
|
||||||
name: trimmedName,
|
|
||||||
description: draft.description,
|
|
||||||
soul: draft.soul,
|
|
||||||
published: nextPublished,
|
|
||||||
skills: draft.skills,
|
|
||||||
common_questions: nextQuestions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
||||||
const wasFeatured = agent.featured_order != null;
|
const wasFeatured = agent.featured_order != null;
|
||||||
if (isAdmin && draft.featured !== wasFeatured) {
|
if (isAdmin && draft.featured !== wasFeatured) {
|
||||||
@@ -568,10 +577,13 @@ export function AgentDetailPanel({
|
|||||||
setDraft({ ...draft, soul: e.currentTarget.value })
|
setDraft({ ...draft, soul: e.currentTarget.value })
|
||||||
}
|
}
|
||||||
placeholder="输入 SOUL.md 内容"
|
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}
|
{soulPreview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -248,3 +248,38 @@ export interface KnowledgeGraph {
|
|||||||
export async function getKnowledgeGraph() {
|
export async function getKnowledgeGraph() {
|
||||||
return parseResponse<KnowledgeGraph>(await apiFetch(`${baseUrl()}/graph`));
|
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";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
addGraphBlacklist,
|
||||||
createKnowledgeNote,
|
createKnowledgeNote,
|
||||||
deleteKnowledgeNote,
|
deleteKnowledgeNote,
|
||||||
exportKnowledgeGraph,
|
exportKnowledgeGraph,
|
||||||
fetchVaultPage,
|
fetchVaultPage,
|
||||||
|
getGraphBlacklist,
|
||||||
getKnowledgeGraph,
|
getKnowledgeGraph,
|
||||||
getKnowledgeNote,
|
getKnowledgeNote,
|
||||||
ingestKnowledgeFile,
|
ingestKnowledgeFile,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
listDuplicates,
|
listDuplicates,
|
||||||
listKnowledgeNotes,
|
listKnowledgeNotes,
|
||||||
mergeKnowledgeNotes,
|
mergeKnowledgeNotes,
|
||||||
|
removeGraphBlacklist,
|
||||||
resolveNoteWikilinks,
|
resolveNoteWikilinks,
|
||||||
searchKnowledge,
|
searchKnowledge,
|
||||||
updateKnowledgeNote,
|
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() {
|
export function useKnowledgeActions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { getAPIClient } from "../api";
|
|||||||
import { apiFetch } from "../api";
|
import { apiFetch } from "../api";
|
||||||
import { getStoredAuth } from "../auth";
|
import { getStoredAuth } from "../auth";
|
||||||
import { rememberAskTime } from "../chat-timestamps";
|
import { rememberAskTime } from "../chat-timestamps";
|
||||||
import { getBackendBaseURL } from "../config";
|
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
|
||||||
import { useI18n } from "../i18n/hooks";
|
import { useI18n } from "../i18n/hooks";
|
||||||
import type { FileInMessage } from "../messages/utils";
|
import type { FileInMessage } from "../messages/utils";
|
||||||
import type { LocalSettings } from "../settings";
|
import type { LocalSettings } from "../settings";
|
||||||
@@ -761,14 +761,86 @@ export function useThreads(
|
|||||||
offset += response.length;
|
offset += response.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return threads.filter(
|
// Safety net on top of the backend's system-thread exclusion: drop
|
||||||
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
|
// 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,
|
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() {
|
export function useDeleteThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const apiClient = getAPIClient();
|
const apiClient = getAPIClient();
|
||||||
|
|||||||
@@ -174,173 +174,176 @@ export default function AgentChatPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
<ChatBox threadId={threadId}>
|
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact
|
||||||
{/* Root: fixed-height horizontal split. The left main column owns the
|
preview) lives in the left flex cell; the agent detail panel is a
|
||||||
chat (home / conversation) and the input; the right sidebar is a
|
separate flex sibling OUTSIDE ChatBox, so the artifact preview
|
||||||
separate flex sibling, so nothing in the main column can ever bleed
|
always opens between the chat and the detail panel — the detail
|
||||||
over it. */}
|
panel stays rightmost. */}
|
||||||
<div className="flex size-full min-h-0 overflow-hidden">
|
<div className="flex size-full min-h-0 overflow-hidden">
|
||||||
{/* Left: main conversation column */}
|
<div className="min-h-0 min-w-0 flex-1">
|
||||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<ChatBox threadId={threadId}>
|
||||||
{/* Header in normal flow, fixed height. */}
|
{/* Main conversation column */}
|
||||||
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
<main className="flex size-full min-h-0 min-w-0 flex-col">
|
||||||
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
{/* Header in normal flow, fixed height. */}
|
||||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
|
||||||
<span
|
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||||
className="max-w-[200px] truncate text-xs font-medium"
|
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||||
title={agentDisplayName}
|
<span
|
||||||
>
|
className="max-w-[200px] truncate text-xs font-medium"
|
||||||
{agentDisplayName}
|
title={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`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PlusSquare /> {t.agents.newChat}
|
{agentDisplayName}
|
||||||
</Button>
|
</span>
|
||||||
</Tooltip>
|
</div>
|
||||||
<TokenUsageIndicator
|
|
||||||
enabled={tokenUsageEnabled}
|
|
||||||
messages={thread.messages}
|
|
||||||
/>
|
|
||||||
<ExportTrigger threadId={threadId} />
|
|
||||||
<ArtifactTrigger />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{isLandingState ? (
|
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||||
/* Home: centered hero, suggested questions, and input.
|
<ThreadTitle threadId={threadId} thread={thread} />
|
||||||
Everything lives inside the scrollable main column. No fixed,
|
</div>
|
||||||
no absolute, no viewport centering, so the avatar can't touch
|
|
||||||
the header and the input can't cover the sidebar. */
|
<div className="flex items-center">
|
||||||
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
|
<Tooltip content={t.agents.newChat}>
|
||||||
<AgentWelcome
|
<Button
|
||||||
agent={agent}
|
size="sm"
|
||||||
agentName={agentDisplayName}
|
variant="secondary"
|
||||||
className="mb-7"
|
onClick={() => {
|
||||||
/>
|
router.push(
|
||||||
<div className="w-full max-w-(--container-width-md)">
|
`/page/workspace/agents/${agent_id}/chats/new`,
|
||||||
{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
|
<PlusSquare /> {t.agents.newChat}
|
||||||
key={index}
|
</Button>
|
||||||
type="button"
|
</Tooltip>
|
||||||
disabled={isDemoMode}
|
<TokenUsageIndicator
|
||||||
onClick={() =>
|
enabled={tokenUsageEnabled}
|
||||||
handleSubmit({ text: question.prompt, files: [] })
|
messages={thread.messages}
|
||||||
}
|
/>
|
||||||
title={desensitize(question.prompt)}
|
<ExportTrigger threadId={threadId} />
|
||||||
className={cn(
|
<ArtifactTrigger />
|
||||||
"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",
|
</div>
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
</header>
|
||||||
)}
|
|
||||||
>
|
{isLandingState ? (
|
||||||
<span className="block max-w-[220px] truncate">
|
/* Home: centered hero, suggested questions, and input.
|
||||||
{desensitize(question.title)}
|
Everything lives inside the scrollable main column. No fixed,
|
||||||
</span>
|
no absolute, no viewport centering, so the avatar can't touch
|
||||||
</button>
|
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>
|
||||||
)}
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
/* Conversation: messages scroll, input
|
||||||
/* Conversation: messages scroll, input
|
docked at the bottom of the main column only. */
|
||||||
docked at the bottom of the main column only. */
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<MessageList
|
||||||
<MessageList
|
className="min-h-0 flex-1"
|
||||||
className="min-h-0 flex-1"
|
threadId={threadId}
|
||||||
threadId={threadId}
|
thread={thread}
|
||||||
thread={thread}
|
paddingBottom={showFollowups ? 96 : 24}
|
||||||
paddingBottom={showFollowups ? 96 : 24}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
onClarificationSubmit={handleClarificationSubmit}
|
||||||
onClarificationSubmit={handleClarificationSubmit}
|
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
|
||||||
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="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)">
|
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
|
||||||
<TodoList
|
<TodoList
|
||||||
className="bg-background/5 mb-2"
|
className="bg-background/5 mb-2"
|
||||||
todos={thread.values.todos ?? []}
|
todos={thread.values.todos ?? []}
|
||||||
hidden={
|
hidden={
|
||||||
!thread.values.todos ||
|
!thread.values.todos ||
|
||||||
thread.values.todos.length === 0
|
thread.values.todos.length === 0
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<InputBox
|
<InputBox
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread={false}
|
isNewThread={false}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode}
|
disabled={isDemoMode}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
onFollowupsVisibilityChange={setShowFollowups}
|
onFollowupsVisibilityChange={setShowFollowups}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
/>
|
/>
|
||||||
{isDemoMode && (
|
{isDemoMode && (
|
||||||
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
|
||||||
{t.common.notAvailableInDemoMode}
|
{t.common.notAvailableInDemoMode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</main>
|
||||||
</main>
|
</ChatBox>
|
||||||
|
|
||||||
{/* Right: agent detail panel. */}
|
|
||||||
<AgentDetailPanel
|
|
||||||
agent={agent}
|
|
||||||
agentName={agentDisplayName}
|
|
||||||
disabled={thread.isLoading || isDemoMode}
|
|
||||||
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</ThreadContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from "@/components/workspace/workspace-container";
|
} from "@/components/workspace/workspace-container";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
useThreads,
|
useThreadsPage,
|
||||||
useToggleThreadFavorite,
|
useToggleThreadFavorite,
|
||||||
useToggleThreadPin,
|
useToggleThreadPin,
|
||||||
} from "@/core/threads/hooks";
|
} from "@/core/threads/hooks";
|
||||||
@@ -42,10 +42,10 @@ const PAGE_SIZE = 20;
|
|||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
const pathOfThread = usePathOfThread();
|
const pathOfThread = usePathOfThread();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: threads } = useThreads();
|
|
||||||
const { toggle: togglePin } = useToggleThreadPin();
|
const { toggle: togglePin } = useToggleThreadPin();
|
||||||
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
const { toggle: toggleFavorite } = useToggleThreadFavorite();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||||
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
const [onlyFavorites, setOnlyFavorites] = useState(false);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
@@ -54,46 +54,50 @@ export default function ChatsPage() {
|
|||||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||||
}, [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.
|
// Reset to the first page whenever the filtered result set changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [search, onlyFavorites]);
|
}, [debouncedSearch, onlyFavorites]);
|
||||||
|
|
||||||
const filteredThreads = useMemo(() => {
|
// Conversations are paginated on the backend: each page is one bounded
|
||||||
return (threads ?? []).filter((thread) => {
|
// query (title search + favorites filter included), so a long history is
|
||||||
if (
|
// never fetched in one go. Pinned conversations come from their own small
|
||||||
thread.metadata?.thread_type === "scheduler" ||
|
// query and are surfaced in a dedicated section on the first page only,
|
||||||
thread.metadata?.thread_type === "roundtable"
|
// and dropped from the paginated rows so they don't repeat on every page.
|
||||||
) {
|
const favoriteFilter = useMemo(
|
||||||
return false;
|
() => (onlyFavorites ? { is_favorite: true } : {}),
|
||||||
}
|
[onlyFavorites],
|
||||||
if (onlyFavorites && !isThreadFavorite(thread)) {
|
);
|
||||||
return false;
|
const pinnedFilter = useMemo(
|
||||||
}
|
() => ({ ...favoriteFilter, is_pinned: true }),
|
||||||
return titleOfThread(thread)
|
[favoriteFilter],
|
||||||
.toLowerCase()
|
);
|
||||||
.includes(search.toLowerCase());
|
const { data: pageData } = useThreadsPage({
|
||||||
});
|
page,
|
||||||
}, [threads, search, onlyFavorites]);
|
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
|
const pinnedThreads = pinnedData?.threads ?? [];
|
||||||
// only, and excluded from the paginated list (so they don't repeat on every
|
const pagedThreads = useMemo(
|
||||||
// page as the user pages through history).
|
() => (pageData?.threads ?? []).filter((t) => !isThreadPinned(t)),
|
||||||
const pinnedThreads = useMemo(
|
[pageData?.threads],
|
||||||
() => 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 currentPage = page;
|
||||||
|
const hasMore = pageData?.hasMore ?? false;
|
||||||
|
|
||||||
const renderThread = (thread: AgentThread) => {
|
const renderThread = (thread: AgentThread) => {
|
||||||
const pinned = isThreadPinned(thread);
|
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 (
|
return (
|
||||||
<WorkspaceContainer>
|
<WorkspaceContainer>
|
||||||
@@ -215,7 +219,7 @@ export default function ChatsPage() {
|
|||||||
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
{onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{totalPages > 1 && (
|
{(currentPage > 1 || hasMore) && (
|
||||||
<div className="flex items-center justify-center gap-3 py-6">
|
<div className="flex items-center justify-center gap-3 py-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -227,15 +231,13 @@ export default function ChatsPage() {
|
|||||||
{t.chats.prevPage}
|
{t.chats.prevPage}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-muted-foreground text-sm tabular-nums">
|
<span className="text-muted-foreground text-sm tabular-nums">
|
||||||
{currentPage} / {totalPages}
|
{currentPage}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={currentPage >= totalPages}
|
disabled={!hasMore}
|
||||||
onClick={() =>
|
onClick={() => setPage((p) => p + 1)}
|
||||||
setPage((p) => Math.min(totalPages, p + 1))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t.chats.nextPage}
|
{t.chats.nextPage}
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight className="size-4" />
|
||||||
|
|||||||
@@ -29,7 +29,59 @@ import {
|
|||||||
} from "@/components/knowledge-graph/graphUtils";
|
} from "@/components/knowledge-graph/graphUtils";
|
||||||
import "@/components/knowledge-graph/graphStyles.css";
|
import "@/components/knowledge-graph/graphStyles.css";
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
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() {
|
export default function KnowledgeGraphPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -39,6 +91,8 @@ export default function KnowledgeGraphPage() {
|
|||||||
|
|
||||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||||
useKnowledgeGraph();
|
useKnowledgeGraph();
|
||||||
|
const { data: blacklistItems = [] } = useGraphBlacklist();
|
||||||
|
const blacklistActions = useGraphBlacklistActions();
|
||||||
|
|
||||||
// ------------------------------------------------------------------ state
|
// ------------------------------------------------------------------ state
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
@@ -50,6 +104,7 @@ export default function KnowledgeGraphPage() {
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(new Set());
|
||||||
|
const [showAllNodes, setShowAllNodes] = useState(false);
|
||||||
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
const [layoutType, setLayoutType] = useState<LayoutType>("force");
|
||||||
const [showLabels, setShowLabels] = useState(true);
|
const [showLabels, setShowLabels] = useState(true);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
@@ -134,6 +189,23 @@ export default function KnowledgeGraphPage() {
|
|||||||
);
|
);
|
||||||
}, [allEdges, filteredNodes, disabledEdgeLabels]);
|
}, [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(() => {
|
const resetExplore = useCallback(() => {
|
||||||
setExploreRootId(null);
|
setExploreRootId(null);
|
||||||
setExpandedNodeIds(new Set());
|
setExpandedNodeIds(new Set());
|
||||||
@@ -244,8 +316,8 @@ export default function KnowledgeGraphPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const displayedGraph = useMemo(() => {
|
const displayedGraph = useMemo(() => {
|
||||||
if (graphMode === "full") return { nodes: filteredNodes, edges: filteredEdges };
|
if (graphMode === "full") return { nodes: limitedNodes, edges: limitedEdges };
|
||||||
if (!exploreRootId) return { nodes: filteredNodes, edges: [] };
|
if (!exploreRootId) return { nodes: limitedNodes, edges: [] };
|
||||||
return {
|
return {
|
||||||
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
nodes: allNodes.filter((node) => visibleNodeIds.has(node.id)),
|
||||||
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
edges: allEdges.filter((edge) => visibleEdgeIds.has(getEdgeId(edge))),
|
||||||
@@ -254,8 +326,8 @@ export default function KnowledgeGraphPage() {
|
|||||||
allEdges,
|
allEdges,
|
||||||
allNodes,
|
allNodes,
|
||||||
exploreRootId,
|
exploreRootId,
|
||||||
filteredEdges,
|
limitedEdges,
|
||||||
filteredNodes,
|
limitedNodes,
|
||||||
graphMode,
|
graphMode,
|
||||||
visibleEdgeIds,
|
visibleEdgeIds,
|
||||||
visibleNodeIds,
|
visibleNodeIds,
|
||||||
@@ -389,6 +461,72 @@ export default function KnowledgeGraphPage() {
|
|||||||
setHiddenNodeIds(new Set());
|
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(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetch().then((result) => {
|
void refetch().then((result) => {
|
||||||
if (result.isError) {
|
if (result.isError) {
|
||||||
@@ -532,6 +670,25 @@ export default function KnowledgeGraphPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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">
|
<div className="kg-floating-pill">
|
||||||
<span className="kg-pill-label">布局</span>
|
<span className="kg-pill-label">布局</span>
|
||||||
<button
|
<button
|
||||||
@@ -608,11 +765,16 @@ export default function KnowledgeGraphPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
topNodes={topNodes}
|
topNodes={topNodes}
|
||||||
hiddenCount={hiddenNodeIds.size}
|
hiddenCount={hiddenNodeIds.size}
|
||||||
|
blacklistItems={blacklistItems}
|
||||||
|
blacklistPending={addToBlacklist.isPending || removeFromBlacklist.isPending}
|
||||||
|
junkCandidateCount={junkCandidates.length}
|
||||||
onSearchChange={setSearchKeyword}
|
onSearchChange={setSearchKeyword}
|
||||||
onSearchEnter={handleSearchEnter}
|
onSearchEnter={handleSearchEnter}
|
||||||
onToggleNodeKind={handleToggleNodeKind}
|
onToggleNodeKind={handleToggleNodeKind}
|
||||||
onToggleEdgeLabel={handleToggleEdgeLabel}
|
onToggleEdgeLabel={handleToggleEdgeLabel}
|
||||||
onRestoreHidden={handleRestoreHidden}
|
onRestoreHidden={handleRestoreHidden}
|
||||||
|
onRemoveBlacklist={handleRemoveBlacklist}
|
||||||
|
onCleanJunk={handleCleanJunk}
|
||||||
onPickNode={handlePickNode}
|
onPickNode={handlePickNode}
|
||||||
onClose={() => setShowSidebar(false)}
|
onClose={() => setShowSidebar(false)}
|
||||||
/>
|
/>
|
||||||
@@ -677,6 +839,7 @@ export default function KnowledgeGraphPage() {
|
|||||||
onViewSource={handleViewSource}
|
onViewSource={handleViewSource}
|
||||||
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
onLocate={(id) => canvasRef.current?.focusNode(id)}
|
||||||
onHide={handleHideNode}
|
onHide={handleHideNode}
|
||||||
|
onBlacklist={handleBlacklistNode}
|
||||||
onPickNeighbor={handlePickNode}
|
onPickNeighbor={handlePickNode}
|
||||||
onExpandNode={expandOneHop}
|
onExpandNode={expandOneHop}
|
||||||
onSetExploreRoot={handleSetExploreRoot}
|
onSetExploreRoot={handleSetExploreRoot}
|
||||||
|
|||||||
@@ -546,7 +546,7 @@ export default function NewAgentPage() {
|
|||||||
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
placeholder="写下智能体的人设、边界、工作方式和输出要求"
|
||||||
value={soulInput}
|
value={soulInput}
|
||||||
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
|||||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||||
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
|
||||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
|
||||||
|
|
||||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||||
|
|
||||||
|
|||||||
@@ -864,9 +864,12 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
tool_groups=next_tool_groups,
|
tool_groups=next_tool_groups,
|
||||||
common_questions=next_common_questions,
|
common_questions=next_common_questions,
|
||||||
)
|
)
|
||||||
# Keep the listing cache in lock-step with config.yaml on every write.
|
# Keep the listing cache in lock-step with config.yaml, but only
|
||||||
await store.replace_skills(agent_id, next_skills)
|
# when the cached fields were actually touched — a partial update
|
||||||
await store.set_extras(agent_id, tool_groups=next_tool_groups, model=next_model)
|
# (e.g. 常用问题 only) skips the DELETE/INSERT churn.
|
||||||
|
if any(key in fields_set for key in ("skills", "model", "tool_groups")):
|
||||||
|
await store.replace_skills(agent_id, next_skills)
|
||||||
|
await store.set_extras(agent_id, tool_groups=next_tool_groups, model=next_model)
|
||||||
if request.soul is not None:
|
if request.soul is not None:
|
||||||
_write_agent_soul(agent_id, request.soul)
|
_write_agent_soul(agent_id, request.soul)
|
||||||
|
|
||||||
|
|||||||
@@ -571,6 +571,37 @@ async def get_graph(request: Request) -> dict[str, Any]:
|
|||||||
return await service.get_graph()
|
return await service.get_graph()
|
||||||
|
|
||||||
|
|
||||||
|
class GraphBlacklistAddRequest(BaseModel):
|
||||||
|
labels: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/graph/blacklist")
|
||||||
|
@require_auth
|
||||||
|
async def list_graph_blacklist(request: Request) -> dict[str, Any]:
|
||||||
|
"""Return blacklisted graph node labels (hidden from the graph)."""
|
||||||
|
service = get_knowledge_service(request)
|
||||||
|
return {"items": await service.list_graph_blacklist()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/graph/blacklist")
|
||||||
|
@require_auth
|
||||||
|
async def add_graph_blacklist(body: GraphBlacklistAddRequest, request: Request) -> dict[str, Any]:
|
||||||
|
"""Blacklist node labels (entity names / note titles); they disappear from the graph."""
|
||||||
|
service = get_knowledge_service(request)
|
||||||
|
added = await service.add_graph_blacklist(body.labels, created_by=get_effective_user_id())
|
||||||
|
return {"added": added, "items": await service.list_graph_blacklist()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/graph/blacklist/{entry_id}")
|
||||||
|
@require_auth
|
||||||
|
async def remove_graph_blacklist(entry_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
"""Remove a blacklist entry so the node shows up in the graph again."""
|
||||||
|
service = get_knowledge_service(request)
|
||||||
|
if not await service.remove_graph_blacklist(entry_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Blacklist entry not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/export/graph")
|
@router.post("/export/graph")
|
||||||
@require_auth
|
@require_auth
|
||||||
async def export_graph(request: Request) -> dict[str, Any]:
|
async def export_graph(request: Request) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ class ThreadSearchRequest(BaseModel):
|
|||||||
limit: int = Field(default=100, ge=1, le=1000, description="Maximum results")
|
limit: int = Field(default=100, ge=1, le=1000, description="Maximum results")
|
||||||
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
||||||
status: str | None = Field(default=None, description="Filter by thread status")
|
status: str | None = Field(default=None, description="Filter by thread status")
|
||||||
|
query: str | None = Field(default=None, description="Case-insensitive substring match on the thread title")
|
||||||
|
|
||||||
|
|
||||||
class ThreadStateResponse(BaseModel):
|
class ThreadStateResponse(BaseModel):
|
||||||
@@ -660,11 +661,19 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
|||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_store
|
||||||
|
|
||||||
repo = get_thread_store(request)
|
repo = get_thread_store(request)
|
||||||
|
# System threads (scheduler runs, roundtable workers, …) carry
|
||||||
|
# ``metadata.system=true`` and are machine-created — hide them from the
|
||||||
|
# default conversation list so they don't crowd out real chats. A caller
|
||||||
|
# that explicitly filters on ``thread_type``/``system`` (e.g. the studio
|
||||||
|
# notebook list) opts out of the exclusion.
|
||||||
|
explicit_system_filter = bool(body.metadata) and ("thread_type" in body.metadata or "system" in body.metadata)
|
||||||
rows = await repo.search(
|
rows = await repo.search(
|
||||||
metadata=body.metadata or None,
|
metadata=body.metadata or None,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
limit=body.limit,
|
limit=body.limit,
|
||||||
offset=body.offset,
|
offset=body.offset,
|
||||||
|
exclude_system=not explicit_system_filter,
|
||||||
|
query=body.query,
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
ThreadResponse(
|
ThreadResponse(
|
||||||
|
|||||||
@@ -25,10 +25,22 @@ def _vault_key(vault_path: str | None) -> str | None:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_blacklist(labels: list[str] | set[str] | None) -> set[str]:
|
||||||
|
"""Normalize blacklist labels for matching (trimmed, lower-cased, non-empty)."""
|
||||||
|
out: set[str] = set()
|
||||||
|
for raw in labels or []:
|
||||||
|
label = (raw or "").strip().lower()
|
||||||
|
if label:
|
||||||
|
out.add(label)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def build_graph(
|
def build_graph(
|
||||||
notes: list[dict[str, Any]],
|
notes: list[dict[str, Any]],
|
||||||
entities: list[dict[str, Any]] | None = None,
|
entities: list[dict[str, Any]] | None = None,
|
||||||
relations: list[dict[str, Any]] | None = None,
|
relations: list[dict[str, Any]] | None = None,
|
||||||
|
*,
|
||||||
|
blacklist: set[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return ``{nodes, edges}`` from notes (+ optional entities/relations).
|
"""Return ``{nodes, edges}`` from notes (+ optional entities/relations).
|
||||||
|
|
||||||
@@ -36,7 +48,20 @@ def build_graph(
|
|||||||
when ``entities``/``relations`` are supplied (phase 4), entity nodes and
|
when ``entities``/``relations`` are supplied (phase 4), entity nodes and
|
||||||
note→entity / entity→entity edges are added on top of the wikilink and
|
note→entity / entity→entity edges are added on top of the wikilink and
|
||||||
shared-tag edges.
|
shared-tag edges.
|
||||||
|
|
||||||
|
``blacklist`` is a set of *normalized* labels (see :func:`normalize_blacklist`);
|
||||||
|
notes whose title and entities whose name match are dropped, together with
|
||||||
|
every edge touching them (edges only join surviving nodes).
|
||||||
"""
|
"""
|
||||||
|
blocked = blacklist or set()
|
||||||
|
|
||||||
|
def _is_blocked(label: str | None) -> bool:
|
||||||
|
return bool(blocked) and (label or "").strip().lower() in blocked
|
||||||
|
|
||||||
|
# Drop blacklisted notes up front so the wikilink / shared-tag edge loops
|
||||||
|
# below never reference them either.
|
||||||
|
notes = [n for n in notes if not _is_blocked(n.get("title"))]
|
||||||
|
|
||||||
nodes = []
|
nodes = []
|
||||||
by_key: dict[str, str] = {}
|
by_key: dict[str, str] = {}
|
||||||
note_ids: set[str] = set()
|
note_ids: set[str] = set()
|
||||||
@@ -59,6 +84,8 @@ def build_graph(
|
|||||||
for ent in entities or []:
|
for ent in entities or []:
|
||||||
if ent["id"] not in mentioned:
|
if ent["id"] not in mentioned:
|
||||||
continue
|
continue
|
||||||
|
if _is_blocked(ent.get("name")):
|
||||||
|
continue
|
||||||
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
|
nodes.append({"id": ent["id"], "type": "entity", "label": ent.get("name") or ent["id"], "category": ent.get("entity_type")})
|
||||||
entity_ids.add(ent["id"])
|
entity_ids.add(ent["id"])
|
||||||
|
|
||||||
@@ -156,11 +183,12 @@ def export_all(
|
|||||||
relations: list[dict[str, Any]] | None = None,
|
relations: list[dict[str, Any]] | None = None,
|
||||||
write_html: bool = True,
|
write_html: bool = True,
|
||||||
write_json: bool = True,
|
write_json: bool = True,
|
||||||
|
blacklist: set[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
|
"""Generate graph artifacts under ``<vault>/wiki-export/`` and return stats."""
|
||||||
out_dir = Path(vault_root).resolve() / "wiki-export"
|
out_dir = Path(vault_root).resolve() / "wiki-export"
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
graph = build_graph(notes, entities, relations)
|
graph = build_graph(notes, entities, relations, blacklist=blacklist)
|
||||||
written: list[str] = []
|
written: list[str] = []
|
||||||
if write_json:
|
if write_json:
|
||||||
(out_dir / "graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
|
(out_dir / "graph.json").write_text(json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|||||||
@@ -126,6 +126,21 @@ class KnowledgeEntityRow(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeGraphBlacklistRow(Base):
|
||||||
|
"""Graph blacklist: node labels (entity names / note titles) hidden from the graph.
|
||||||
|
|
||||||
|
Matching is case-insensitive on the trimmed label. Global/shared like the
|
||||||
|
rest of the knowledge base; ``created_by`` is audit-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "knowledge_graph_blacklist"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(512), unique=True, index=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeRelationRow(Base):
|
class KnowledgeRelationRow(Base):
|
||||||
"""Relation between notes/entities (phase 4, mirrors ``knowledge_relations``)."""
|
"""Relation between notes/entities (phase 4, mirrors ``knowledge_relations``)."""
|
||||||
|
|
||||||
|
|||||||
+2
@@ -150,6 +150,7 @@ class ObsidianWikiAdapter:
|
|||||||
entities: list[dict[str, Any]] | None = None,
|
entities: list[dict[str, Any]] | None = None,
|
||||||
relations: list[dict[str, Any]] | None = None,
|
relations: list[dict[str, Any]] | None = None,
|
||||||
updated: str,
|
updated: str,
|
||||||
|
blacklist: set[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
self.ensure_ready()
|
self.ensure_ready()
|
||||||
result = exporter.export_all(
|
result = exporter.export_all(
|
||||||
@@ -159,6 +160,7 @@ class ObsidianWikiAdapter:
|
|||||||
relations=relations,
|
relations=relations,
|
||||||
write_html=self.export_html,
|
write_html=self.export_html,
|
||||||
write_json=self.export_json,
|
write_json=self.export_json,
|
||||||
|
blacklist=blacklist,
|
||||||
)
|
)
|
||||||
self._append_log("EXPORT", "graph", f"nodes={result['stats']['nodes']} edges={result['stats']['edges']}", updated)
|
self._append_log("EXPORT", "graph", f"nodes={result['stats']['nodes']} edges={result['stats']['edges']}", updated)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||||||
from deerflow.knowledge.models import (
|
from deerflow.knowledge.models import (
|
||||||
KnowledgeEmbeddingRow,
|
KnowledgeEmbeddingRow,
|
||||||
KnowledgeEntityRow,
|
KnowledgeEntityRow,
|
||||||
|
KnowledgeGraphBlacklistRow,
|
||||||
KnowledgeNoteRow,
|
KnowledgeNoteRow,
|
||||||
KnowledgeNoteTagRow,
|
KnowledgeNoteTagRow,
|
||||||
KnowledgeNoteVersionRow,
|
KnowledgeNoteVersionRow,
|
||||||
@@ -485,6 +486,49 @@ class KnowledgeRepository:
|
|||||||
rows = (await session.execute(select(KnowledgeEntityRow))).scalars()
|
rows = (await session.execute(select(KnowledgeEntityRow))).scalars()
|
||||||
return [{"id": r.id, "name": r.name, "entity_type": r.entity_type} for r in rows]
|
return [{"id": r.id, "name": r.name, "entity_type": r.entity_type} for r in rows]
|
||||||
|
|
||||||
|
# -- graph blacklist ---------------------------------------------------
|
||||||
|
async def list_graph_blacklist(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return every blacklisted graph node label, newest first."""
|
||||||
|
async with self._sf() as session:
|
||||||
|
stmt = select(KnowledgeGraphBlacklistRow).order_by(KnowledgeGraphBlacklistRow.created_at.desc())
|
||||||
|
rows = (await session.execute(stmt)).scalars()
|
||||||
|
return [{"id": r.id, "label": r.label, "created_by": r.created_by, "created_at": _iso(r.created_at)} for r in rows]
|
||||||
|
|
||||||
|
async def add_graph_blacklist(self, labels: list[str], *, created_by: str | None = None) -> int:
|
||||||
|
"""Add labels to the graph blacklist (trimmed, case-insensitive dedup). Returns the count actually added."""
|
||||||
|
cleaned: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in labels or []:
|
||||||
|
label = (raw or "").strip()
|
||||||
|
if not label or label.lower() in seen:
|
||||||
|
continue
|
||||||
|
seen.add(label.lower())
|
||||||
|
cleaned.append(label)
|
||||||
|
if not cleaned:
|
||||||
|
return 0
|
||||||
|
async with self._sf() as session:
|
||||||
|
existing = {
|
||||||
|
value
|
||||||
|
for (value,) in (
|
||||||
|
await session.execute(select(func.lower(KnowledgeGraphBlacklistRow.label)).where(func.lower(KnowledgeGraphBlacklistRow.label).in_([label.lower() for label in cleaned])))
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
added = 0
|
||||||
|
for label in cleaned:
|
||||||
|
if label.lower() in existing:
|
||||||
|
continue
|
||||||
|
session.add(KnowledgeGraphBlacklistRow(id=_new_id("kbblk"), label=label, created_by=created_by, created_at=datetime.now(UTC)))
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
await session.commit()
|
||||||
|
return added
|
||||||
|
|
||||||
|
async def remove_graph_blacklist(self, entry_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.execute(delete(KnowledgeGraphBlacklistRow).where(KnowledgeGraphBlacklistRow.id == entry_id))
|
||||||
|
await session.commit()
|
||||||
|
return bool(result.rowcount)
|
||||||
|
|
||||||
async def list_relations(self) -> list[dict[str, Any]]:
|
async def list_relations(self) -> list[dict[str, Any]]:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
rows = (await session.execute(select(KnowledgeRelationRow))).scalars()
|
rows = (await session.execute(select(KnowledgeRelationRow))).scalars()
|
||||||
|
|||||||
@@ -796,7 +796,8 @@ class KnowledgeService:
|
|||||||
notes = await self.repo.list_all()
|
notes = await self.repo.list_all()
|
||||||
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
|
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
|
||||||
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
|
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
|
||||||
result = self.adapter.export_graph(notes, entities=entities, relations=relations, updated=now_iso())
|
blacklist = await self._graph_blacklist_set()
|
||||||
|
result = self.adapter.export_graph(notes, entities=entities, relations=relations, updated=now_iso(), blacklist=blacklist)
|
||||||
return {"files": result["files"], "stats": result["stats"]}
|
return {"files": result["files"], "stats": result["stats"]}
|
||||||
|
|
||||||
async def get_graph(self) -> dict[str, Any]:
|
async def get_graph(self) -> dict[str, Any]:
|
||||||
@@ -806,7 +807,26 @@ class KnowledgeService:
|
|||||||
notes = await self.repo.list_all()
|
notes = await self.repo.list_all()
|
||||||
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
|
entities = await self.repo.list_entities() if self.config.entity_extraction_enabled else []
|
||||||
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
|
relations = await self.repo.list_relations() if self.config.entity_extraction_enabled else []
|
||||||
return exporter.build_graph(notes, entities, relations)
|
blacklist = await self._graph_blacklist_set()
|
||||||
|
return exporter.build_graph(notes, entities, relations, blacklist=blacklist)
|
||||||
|
|
||||||
|
# -- graph blacklist ---------------------------------------------------
|
||||||
|
async def _graph_blacklist_set(self) -> set[str]:
|
||||||
|
from deerflow.knowledge import exporter
|
||||||
|
|
||||||
|
items = await self.repo.list_graph_blacklist()
|
||||||
|
return exporter.normalize_blacklist([item["label"] for item in items])
|
||||||
|
|
||||||
|
async def list_graph_blacklist(self) -> list[dict[str, Any]]:
|
||||||
|
"""Return blacklist entries (node labels hidden from the graph)."""
|
||||||
|
return await self.repo.list_graph_blacklist()
|
||||||
|
|
||||||
|
async def add_graph_blacklist(self, labels: list[str], *, created_by: str | None = None) -> int:
|
||||||
|
"""Blacklist node labels (entity names / note titles). Returns the count added."""
|
||||||
|
return await self.repo.add_graph_blacklist(labels, created_by=created_by)
|
||||||
|
|
||||||
|
async def remove_graph_blacklist(self, entry_id: str) -> bool:
|
||||||
|
return await self.repo.remove_graph_blacklist(entry_id)
|
||||||
|
|
||||||
def export_file_path(self, filename: str) -> Path | None:
|
def export_file_path(self, filename: str) -> Path | None:
|
||||||
return self.adapter.export_file_path(filename)
|
return self.adapter.export_file_path(filename)
|
||||||
+2
@@ -28,6 +28,7 @@ from deerflow.persistence.feedback.model import FeedbackRow
|
|||||||
from deerflow.knowledge.models import (
|
from deerflow.knowledge.models import (
|
||||||
KnowledgeEmbeddingRow,
|
KnowledgeEmbeddingRow,
|
||||||
KnowledgeEntityRow,
|
KnowledgeEntityRow,
|
||||||
|
KnowledgeGraphBlacklistRow,
|
||||||
KnowledgeNoteRow,
|
KnowledgeNoteRow,
|
||||||
KnowledgeNoteTagRow,
|
KnowledgeNoteTagRow,
|
||||||
KnowledgeNoteVersionRow,
|
KnowledgeNoteVersionRow,
|
||||||
@@ -75,6 +76,7 @@ __all__ = [
|
|||||||
"BusinessMappingRow",
|
"BusinessMappingRow",
|
||||||
"KnowledgeEmbeddingRow",
|
"KnowledgeEmbeddingRow",
|
||||||
"KnowledgeEntityRow",
|
"KnowledgeEntityRow",
|
||||||
|
"KnowledgeGraphBlacklistRow",
|
||||||
"KnowledgeNoteRow",
|
"KnowledgeNoteRow",
|
||||||
"KnowledgeNoteTagRow",
|
"KnowledgeNoteTagRow",
|
||||||
"KnowledgeNoteVersionRow",
|
"KnowledgeNoteVersionRow",
|
||||||
|
|||||||
+11
@@ -45,7 +45,18 @@ class ThreadMetaStore(abc.ABC):
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
exclude_system: bool = False,
|
||||||
|
query: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
|
"""Search threads ordered by ``updated_at`` desc.
|
||||||
|
|
||||||
|
``exclude_system=True`` drops threads whose ``metadata.system`` is
|
||||||
|
truthy (scheduler runs, roundtable workers, …) so user-facing
|
||||||
|
conversation lists are not crowded out by machine-created threads.
|
||||||
|
``query`` is a case-insensitive substring match on the thread title
|
||||||
|
(``display_name``). ``limit``/``offset`` apply to the *filtered*
|
||||||
|
result set.
|
||||||
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|||||||
+41
-7
@@ -74,6 +74,8 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
exclude_system: bool = False,
|
||||||
|
query: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
||||||
filter_dict: dict[str, Any] = {}
|
filter_dict: dict[str, Any] = {}
|
||||||
@@ -84,13 +86,45 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
if resolved_user_id is not None:
|
if resolved_user_id is not None:
|
||||||
filter_dict["user_id"] = resolved_user_id
|
filter_dict["user_id"] = resolved_user_id
|
||||||
|
|
||||||
items = await self._store.asearch(
|
normalized_query = (query or "").strip().lower()
|
||||||
THREADS_NS,
|
if not exclude_system and not normalized_query:
|
||||||
filter=filter_dict or None,
|
items = await self._store.asearch(
|
||||||
limit=limit,
|
THREADS_NS,
|
||||||
offset=offset,
|
filter=filter_dict or None,
|
||||||
)
|
limit=limit,
|
||||||
return [self._item_to_dict(item) for item in items]
|
offset=offset,
|
||||||
|
)
|
||||||
|
return [self._item_to_dict(item) for item in items]
|
||||||
|
|
||||||
|
# The Store filter only supports equality, so the system-thread
|
||||||
|
# exclusion and title search are applied in Python: scan in batches
|
||||||
|
# until the requested window is full so ``limit``/``offset`` refer to
|
||||||
|
# filtered rows.
|
||||||
|
def _matches(record: dict) -> bool:
|
||||||
|
if exclude_system and (record.get("metadata") or {}).get("system"):
|
||||||
|
return False
|
||||||
|
if normalized_query and normalized_query not in (record.get("display_name") or "").lower():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
needed = offset + limit
|
||||||
|
batch_size = max(500, needed)
|
||||||
|
matched: list[dict] = []
|
||||||
|
scan_offset = 0
|
||||||
|
while len(matched) < needed:
|
||||||
|
items = await self._store.asearch(
|
||||||
|
THREADS_NS,
|
||||||
|
filter=filter_dict or None,
|
||||||
|
limit=batch_size,
|
||||||
|
offset=scan_offset,
|
||||||
|
)
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
matched.extend(record for record in (self._item_to_dict(item) for item in items) if _matches(record))
|
||||||
|
if len(items) < batch_size:
|
||||||
|
break
|
||||||
|
scan_offset += len(items)
|
||||||
|
return matched[offset:needed]
|
||||||
|
|
||||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||||
item = await self._store.aget(THREADS_NS, thread_id)
|
item = await self._store.aget(THREADS_NS, thread_id)
|
||||||
|
|||||||
+42
-13
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||||
@@ -109,6 +109,8 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
|
exclude_system: bool = False,
|
||||||
|
query: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Search threads with optional metadata and status filters.
|
"""Search threads with optional metadata and status filters.
|
||||||
|
|
||||||
@@ -116,28 +118,55 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
||||||
"""
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
||||||
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc())
|
# thread_id tie-breaker keeps OFFSET pagination stable when many rows
|
||||||
|
# share the same updated_at (bulk imports, scheduler bursts).
|
||||||
|
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
|
||||||
if resolved_user_id is not None:
|
if resolved_user_id is not None:
|
||||||
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(ThreadMetaRow.status == status)
|
stmt = stmt.where(ThreadMetaRow.status == status)
|
||||||
|
if query and query.strip():
|
||||||
|
# Case-insensitive title search. display_name is a real column, so
|
||||||
|
# this filter runs in SQL even when the JSON filters below fall
|
||||||
|
# back to Python-side scanning.
|
||||||
|
stmt = stmt.where(func.lower(ThreadMetaRow.display_name).contains(query.strip().lower()))
|
||||||
|
|
||||||
if metadata:
|
if not metadata and not exclude_system:
|
||||||
# When metadata filter is active, fetch a larger window and filter
|
|
||||||
# in Python. TODO(Phase 2): use JSON DB operators (Postgres @>,
|
|
||||||
# SQLite json_extract) for server-side filtering.
|
|
||||||
stmt = stmt.limit(limit * 5 + offset)
|
|
||||||
async with self._sf() as session:
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
rows = [self._row_to_dict(r) for r in result.scalars()]
|
|
||||||
rows = [r for r in rows if all(r.get("metadata", {}).get(k) == v for k, v in metadata.items())]
|
|
||||||
return rows[offset : offset + limit]
|
|
||||||
else:
|
|
||||||
stmt = stmt.limit(limit).offset(offset)
|
stmt = stmt.limit(limit).offset(offset)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return [self._row_to_dict(r) for r in result.scalars()]
|
return [self._row_to_dict(r) for r in result.scalars()]
|
||||||
|
|
||||||
|
# Metadata filters live inside the JSON blob, so rows are scanned in
|
||||||
|
# batches and filtered in Python until the requested window is full.
|
||||||
|
# A fixed over-fetch window would under-fill pages when most rows are
|
||||||
|
# filtered out (e.g. heavy scheduled-task usage), making callers that
|
||||||
|
# paginate believe the list is exhausted. TODO(Phase 2): use JSON DB
|
||||||
|
# operators (Postgres @>, SQLite json_extract) for server-side filtering.
|
||||||
|
def _matches(row: dict) -> bool:
|
||||||
|
md = row.get("metadata") or {}
|
||||||
|
if metadata and not all(md.get(k) == v for k, v in metadata.items()):
|
||||||
|
return False
|
||||||
|
if exclude_system and md.get("system"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
needed = offset + limit
|
||||||
|
batch_size = max(500, needed)
|
||||||
|
matched: list[dict] = []
|
||||||
|
scan_offset = 0
|
||||||
|
async with self._sf() as session:
|
||||||
|
while len(matched) < needed:
|
||||||
|
result = await session.execute(stmt.limit(batch_size).offset(scan_offset))
|
||||||
|
rows = result.scalars().all()
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
matched.extend(d for d in (self._row_to_dict(r) for r in rows) if _matches(d))
|
||||||
|
if len(rows) < batch_size:
|
||||||
|
break
|
||||||
|
scan_offset += len(rows)
|
||||||
|
return matched[offset:needed]
|
||||||
|
|
||||||
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
async def _check_ownership(self, session: AsyncSession, thread_id: str, resolved_user_id: str | None) -> bool:
|
||||||
"""Return True if the row exists and is owned (or filter bypassed)."""
|
"""Return True if the row exists and is owned (or filter bypassed)."""
|
||||||
if resolved_user_id is None:
|
if resolved_user_id is None:
|
||||||
|
|||||||
Reference in new issue
Block a user