feat: 完善知识图谱交互与 Agent/线程持久化

- 知识图谱画布、侧边栏、详情面板交互优化,新增图谱相关 API 与 hooks
- Agent 卡片与详情面板调整,对话页面重构
- 线程元数据持久化增强(base/memory/sql),knowledge 仓储与导出补充
- 网关路由 agents/knowledge/threads 同步更新

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 19:58:34 +08:00
1 parent ea78fe9726
commit f107ebc46d
25 files changed
+929 -279

No files matched your search

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