diff --git a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx index 16ad2250..8b3b3bc7 100644 --- a/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx +++ b/frontend-web/src/components/knowledge-graph/GraphCanvas.tsx @@ -148,6 +148,9 @@ export const GraphCanvas = forwardRef( } | 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( 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( sim.reheat(0.9); } autoFitRef.current = false; + interactedRef.current = false; requestDraw(); }, [props.layoutType, requestDraw]); @@ -295,6 +313,7 @@ export const GraphCanvas = forwardRef( 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( 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( seedPositions(sim.nodes); sim.alpha = 1; autoFitRef.current = false; + interactedRef.current = false; } requestDraw(); }, [fitView, requestDraw]); @@ -678,12 +699,16 @@ export const GraphCanvas = forwardRef( 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( const handlePointerDown = (e: React.PointerEvent) => { 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( if (!canvas) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); + interactedRef.current = true; const rect = canvas.getBoundingClientRect(); zoomBy( Math.exp(-e.deltaY * 0.0012), diff --git a/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx index 5dec2078..e9ecb997 100644 --- a/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx +++ b/frontend-web/src/components/knowledge-graph/GraphDetailPanel.tsx @@ -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 }) { > 隐藏节点 + {props.explore ? ( <> + {props.blacklistItems.length === 0 ? ( +
暂无黑名单条目。
+ ) : ( + props.blacklistItems.map((item) => ( +
+ + + {item.label} + + + +
+ )) + )} + ); diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 91018a6b..0b059976 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -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" />
diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 42f79179..15d0e31e 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -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" /> ) : ( -

+ // 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. +

{soulPreview}

)} diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index 45cd196e..3323bd5d 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -248,3 +248,38 @@ export interface KnowledgeGraph { export async function getKnowledgeGraph() { return parseResponse(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", + }), + ); +} diff --git a/frontend-web/src/core/knowledge/hooks.ts b/frontend-web/src/core/knowledge/hooks.ts index bc313ea9..2c2e1b46 100644 --- a/frontend-web/src/core/knowledge/hooks.ts +++ b/frontend-web/src/core/knowledge/hooks.ts @@ -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 = () => diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 6a021e46..121b84e8 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -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 | 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 | 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; +}) { + const { page, pageSize, search, metadata } = options; + const normalizedSearch = search?.trim() ?? ""; + return useQuery({ + 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(); diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 4db506d1..8d243775 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -174,173 +174,176 @@ export default function AgentChatPage() { return ( - - {/* 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. */} -
- {/* Left: main conversation column */} -
- {/* Header in normal flow, fixed height. */} -
-
- - - {agentDisplayName} - -
- -
- -
- -
- - - - - - -
-
+ {agentDisplayName} + +
- {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. */ -
- -
- {commonQuestions.length > 0 && ( -
- {commonQuestions.map((question, index) => ( - - ))} +
+ +
+ +
+ + + + + + +
+ + + {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. */ +
+ +
+ {commonQuestions.length > 0 && ( +
+ {commonQuestions.map((question, index) => ( + + ))} +
+ )} +
+ + setSettings("context", context) + } + onFollowupsVisibilityChange={setShowFollowups} + onSubmit={handleSubmit} + onStop={handleStop} + /> + {isDemoMode && ( +
+ {t.common.notAvailableInDemoMode} +
+ )}
- )} -
- - setSettings("context", context) - } - onFollowupsVisibilityChange={setShowFollowups} - onSubmit={handleSubmit} - onStop={handleStop} - /> - {isDemoMode && ( -
- {t.common.notAvailableInDemoMode} -
- )}
-
- ) : ( - /* Conversation: messages scroll, input - docked at the bottom of the main column only. */ -
- handleSubmit({ text, files: [] })} - /> + ) : ( + /* Conversation: messages scroll, input + docked at the bottom of the main column only. */ +
+ handleSubmit({ text, files: [] })} + /> -
-
-
-
- )} - - - {/* Right: agent detail panel. */} - handleSubmit({ text, files: [] })} - /> + )} + +
- + + {/* Right: agent detail panel — always the rightmost column. */} + handleSubmit({ text, files: [] })} + /> +
); } diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index ed2a3f9c..e0bb9aad 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -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 ( @@ -215,7 +219,7 @@ export default function ChatsPage() { {onlyFavorites ? t.chats.noFavorites : t.chats.noChats}
)} - {totalPages > 1 && ( + {(currentPage > 1 || hasMore) && (
) : null} + {graphMode === "full" && filteredNodes.length > FULL_GRAPH_NODE_LIMIT ? ( +
+ {fullGraphTruncated + ? `节点较多,已显示核心 ${FULL_GRAPH_NODE_LIMIT} / ${filteredNodes.length}` + : `已显示全部 ${filteredNodes.length} 个节点`} + +
+ ) : null}
布局
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 695d6884..c49d7af1 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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. diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index e5a9bffa..75d8f9cd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py index 7b8e4e29..2c92ffe1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/knowledge.py +++ b/offline-backend-20260512/backend/app/gateway/routers/knowledge.py @@ -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]: diff --git a/offline-backend-20260512/backend/app/gateway/routers/threads.py b/offline-backend-20260512/backend/app/gateway/routers/threads.py index 5af5b5f9..3024f488 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/threads.py +++ b/offline-backend-20260512/backend/app/gateway/routers/threads.py @@ -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( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py index abb509af..837dabd0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/exporter.py @@ -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 ``/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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py index 07a4e338..5e5adc9b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/models.py @@ -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``).""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py index fef599bc..c4f06050 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/obsidian_adapter.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py index 6481d79f..436489b4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/repository.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py index 71d5f245..38e92dad 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/knowledge/service.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 18566d1f..0a83dc4d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py index 29a89ed7..f2602442 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py index b5d8a876..16e86ab0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/memory.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index c9a70f31..4ad82013 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -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: