From b81d200eca86505bb92cba03c52eea5ea0b441b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 23 Aug 2026 20:29:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8D=8F=E5=8A=A9=E5=8D=A1=E5=81=9C?= =?UTF-8?q?=E8=BD=AE=E4=B8=8E=E6=8F=90=E4=BA=A4=E9=98=B2=20409=EF=BC=8C?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E9=85=8D=E7=BD=AE=E5=90=91=E5=AF=BC=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=EF=BC=8C=E5=B9=B6=E9=99=90=E5=88=B6=E5=B2=97=E4=BD=8D?= =?UTF-8?q?=E4=BC=9A=E5=95=86=E4=BA=A7=E7=89=A9=E9=87=8D=E5=A4=8D=E5=86=99?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。 Co-authored-by: Cursor --- .../workspace/agents/agent-chat-sidebar.tsx | 14 +- .../workspace/agents/agent-recent-chats.tsx | 9 +- .../workspace/general-qa-sidebar.tsx | 11 +- .../components/workspace/recent-chat-list.tsx | 9 +- frontend-web/src/core/studio/hooks.ts | 10 +- frontend-web/src/core/threads/hooks.ts | 57 +- frontend-web/src/core/threads/utils.ts | 38 + .../ai-writing/WritingSetupChat.tsx | 15 +- .../components/chat/CanvasChatPanel.tsx | 4 +- frontend-web/src/pages/AgentChatPage.tsx | 9 +- frontend-web/src/pages/ChatPage.tsx | 9 +- frontend-web/src/pages/EmbedChatPage.tsx | 9 +- frontend-web/src/pages/IframeChatPage.tsx | 9 +- frontend-web/src/pages/NewAgentPage.tsx | 18 +- .../src/pages/StudioNotebookDetailPage.tsx | 8 +- .../deep-research/DeepResearchWorkbench.tsx | 10 +- .../ReportStructureSetupChat.tsx | 15 +- .../deep-research/WorkflowConfigDialog.tsx | 1119 +++++++++++------ .../components/PositionBuiltinAgentChat.tsx | 28 +- .../components/PositionNodeChat.tsx | 16 +- frontend-web/src/styles/globals.css | 20 + offline-backend-20260512/backend/CLAUDE.md | 10 + .../gateway/routers/position_roundtable.py | 6 +- .../backend/app/gateway/routers/threads.py | 11 +- .../backend/app/gateway/services.py | 2 + .../deerflow/agents/lead_agent/agent.py | 7 + .../middlewares/clarification_middleware.py | 160 +-- .../position_artifact_cap_middleware.py | 376 ++++++ .../deerflow/persistence/thread_meta/base.py | 30 +- .../persistence/thread_meta/memory.py | 6 +- .../deerflow/persistence/thread_meta/sql.py | 6 +- 31 files changed, 1459 insertions(+), 592 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/position_artifact_cap_middleware.py diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 8f75052a..45f39694 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -72,6 +72,7 @@ import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { bootstrapResumePath, isBootstrapThread, + isHiddenFromRecentChats, isThreadFavorite, isThreadPinned, sortThreadsPinnedFirst, @@ -372,17 +373,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(null); - // 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示 - // (带机器人图标、点击回创建页);置顶优先。 + // 排除调度/会商/报告结构&写作配置向导等系统会话,与首页一致; + // 智能体创建(bootstrap)会话保留展示(带机器人图标、点击回创建页);置顶优先。 const visibleThreads = useMemo( - () => - sortThreadsPinnedFirst( - threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable", - ), - ), + () => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), [threads], ); diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx index de416639..3bdf1204 100644 --- a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useThreadsPage } from "@/core/threads/hooks"; -import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -22,12 +22,9 @@ export function AgentRecentChats({ agentId }: { agentId: string }) { pageSize: RECENT_LIMIT, metadata: { agent_id: agentId }, }); - // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + // 该智能体的会话里同样排除引导 / 系统 / 配置向导会话。 const threads = (data?.threads ?? []).filter( - (th) => - th.metadata?.thread_type !== "bootstrap" && - th.metadata?.thread_type !== "scheduler" && - th.metadata?.thread_type !== "roundtable", + (th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th), ); return ( diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index 51f47ad8..46d26231 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -76,6 +76,7 @@ import { import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { isBootstrapThread, + isHiddenFromRecentChats, isThreadFavorite, isThreadPinned, listPathOfThread, @@ -226,14 +227,8 @@ export function GeneralQaSidebar() { const visibleThreads = useMemo( () => // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); - // 仅排除调度 / 会商类系统会话。 - sortThreadsPinnedFirst( - threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable", - ), - ), + // 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。 + sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))), [threads], ); diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index b996d0a3..3474188c 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -66,6 +66,7 @@ import { import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { isBootstrapThread, + isHiddenFromRecentChats, isThreadFavorite, isThreadPinned, listPathOfThread, @@ -193,13 +194,9 @@ export function RecentChatList({ ); // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); - // 仅排除调度 / 会商类系统会话。 + // 排除调度 / 会商 / 报告结构&写作配置向导等系统会话。 const visibleThreads = sortThreadsPinnedFirst( - threads.filter( - (thread) => - thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "roundtable", - ), + threads.filter((thread) => !isHiddenFromRecentChats(thread)), ); if (visibleThreads.length === 0) { diff --git a/frontend-web/src/core/studio/hooks.ts b/frontend-web/src/core/studio/hooks.ts index 7d468134..9b79eabe 100644 --- a/frontend-web/src/core/studio/hooks.ts +++ b/frontend-web/src/core/studio/hooks.ts @@ -99,8 +99,14 @@ export function useNotebookChat( }, }); - const send = async (text: string, selectedSourceIds: number[]) => { - if (thread.isLoading || !token) return; + const send = async ( + text: string, + selectedSourceIds: number[], + options?: { fromClarification?: boolean }, + ) => { + if (!token) return; + // 协助卡提交前会先停轮;此时 isLoading 可能仍为 true,不能直接丢弃。 + if (thread.isLoading && !options?.fromClarification) return; // API 需要 document.id,而不是 source.id const toDocIds = (ids: number[]) => ids diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 5f64fa67..a46229e0 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -28,6 +28,7 @@ import { isAbortLikeError, isTransientStreamTransportError, } from "./thread-access-error"; +import { isHiddenFromRecentChats, shouldHideSystemThreadsFromList } from "./utils"; export type ToolEndEvent = { name: string; @@ -210,6 +211,39 @@ export async function stopThreadRun( } } +/** + * 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run + * 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。 + */ +export async function stopActiveThreadBeforeClarificationReply( + threadId: string | null | undefined, + thread: { isLoading?: boolean; stop: () => void | Promise }, +): Promise { + let storedRunId: string | null = null; + if (threadId && typeof window !== "undefined") { + try { + storedRunId = normalizeStoredRunId( + window.sessionStorage.getItem(`lg:stream:${threadId}`), + ); + } catch { + storedRunId = null; + } + } + if (!thread.isLoading && !storedRunId) return; + try { + await stopThreadRun(threadId); + } catch { + /* best-effort */ + } + try { + await Promise.resolve(thread.stop()); + } catch { + /* best-effort */ + } + // cancel 落地需要一拍,立刻 submit 仍可能 409。 + await new Promise((resolve) => setTimeout(resolve, 120)); +} + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; @@ -1451,18 +1485,10 @@ export function useThreads( } // 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" - ); - }); + // machine-created / wizard threads (scheduled tasks, report-structure + // setup, writing setup, notebooks, …) in case an older backend still + // returns them. + return threads.filter((t) => !isHiddenFromRecentChats(t)); }, refetchOnWindowFocus: false, }); @@ -1545,8 +1571,13 @@ export function useThreadsPage(options: { throw new Error("Failed to load conversations"); } const rows = (await response.json()) as AgentThread[]; + // 与后端 exclude_system 对齐:普通列表再兜一层。调用方显式按 + // thread_type / system 取数(笔记本等)时不滤,避免把目标会话藏掉。 + const visible = shouldHideSystemThreadsFromList(metadata) + ? rows.filter((thread) => !isHiddenFromRecentChats(thread)) + : rows; return { - threads: rows.slice(0, pageSize), + threads: visible.slice(0, pageSize), hasMore: rows.length > pageSize, }; }, diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index adc0ded4..3efc5f04 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -91,6 +91,44 @@ export function isBootstrapThread(thread: { return thread.metadata?.thread_type === "bootstrap"; } +/** + * 不应出现在普通对话列表里的 thread_type。 + * 与后端 ``EXCLUDED_SYSTEM_THREAD_TYPES`` 保持同步;bootstrap 不在此列(侧栏仍展示)。 + */ +export const HIDDEN_FROM_RECENT_CHAT_TYPES = new Set([ + "scheduler", + "roundtable", + "notebook", + "deep_research", + "writing_setup", + "report_structure_setup", +]); + +export function isSetupWizardThread(thread: { + metadata?: Record | null; +}): boolean { + const threadType = thread.metadata?.thread_type; + return threadType === "writing_setup" || threadType === "report_structure_setup"; +} + +/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ +export function isHiddenFromRecentChats(thread: { + metadata?: Record | null; +}): boolean { + const metadata = thread.metadata; + if (metadata?.system === true) return true; + const threadType = metadata?.thread_type; + return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType); +} + +/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */ +export function shouldHideSystemThreadsFromList( + metadata?: Record | null, +): boolean { + if (!metadata) return true; + return !("thread_type" in metadata || "system" in metadata); +} + /** * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx index 0b45ec4d..7a17d6a5 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -26,7 +26,7 @@ import { ThreadContext } from '@/components/workspace/messages/context'; import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils'; import { getAPIClient } from '@/core/api'; import { useModels } from '@/core/models/hooks'; -import { useThreadStream } from '@/core/threads/hooks'; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from '@/core/threads/hooks'; import { uuid } from '@/core/utils/uuid'; import { cn } from '@/lib/utils'; @@ -146,7 +146,10 @@ export function WritingSetupChat({ const ensureThreadExists = useCallback(async () => { if (threadPrepared) return; - await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } }); + await getAPIClient().threads.create({ + threadId, + metadata: { thread_type: 'writing_setup', system: true }, + }); setThreadPrepared(true); }, [threadId, threadPrepared]); @@ -167,15 +170,9 @@ export function WritingSetupChat({ const handleClarificationSubmit = useCallback( async (text: string) => { - if (thread.isLoading) { - try { - await Promise.resolve(thread.stop()); - } catch { - /* still send the answer */ - } - } const trimmed = text.trim(); if (!trimmed) return; + await stopActiveThreadBeforeClarificationReply(threadId, thread); await ensureThreadExists(); await sendMessage(threadId, { text: trimmed, files: [] }, buildWritingSampleContext(formCtx)); }, diff --git a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx index 9a1e3ab4..cd214263 100644 --- a/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx +++ b/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx @@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea"; import { MessageList } from "@/components/workspace/messages"; import { useModels } from "@/core/models/hooks"; import type { AgentThreadState } from "@/core/threads"; +import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { cn } from "@/lib/utils"; import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext"; @@ -177,9 +178,10 @@ export function CanvasChatPanel() { const handleClarificationSubmit = useCallback( async (answer: string) => { + await stopActiveThreadBeforeClarificationReply(threadId, thread); await sendMessage(answer, { mode }); }, - [sendMessage, mode], + [sendMessage, mode, thread, threadId], ); const handleModelSelect = useCallback( diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 0d9d57a3..86c4583d 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -31,7 +31,7 @@ import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-dep import { useModels } from "@/core/models/hooks"; import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; -import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { OptType, writeYLog } from "@/core/log/y-log"; import { @@ -542,11 +542,12 @@ export default function AgentChatPage({ }, [taskAutoStart, taskStartText, isNewThread, handleSubmit]); const handleClarificationSubmit = useCallback( - (answer: string) => { + async (answer: string) => { setHasStartedConversation(true); - void sendMessage(threadId, { text: answer, files: [] }, { agent_id }); + await stopActiveThreadBeforeClarificationReply(threadId, thread); + await sendMessage(threadId, { text: answer, files: [] }, { agent_id }); }, - [sendMessage, threadId, agent_id], + [sendMessage, thread, threadId, agent_id], ); const handleStop = useCallback(async () => { diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 485d31cc..5e95df8e 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -58,7 +58,7 @@ import { useKnowledgeRagEnabled, useThreadSettings, } from "@/core/settings"; -import { useThreadStream } from "@/core/threads/hooks"; +import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag"; @@ -525,11 +525,12 @@ export default function ChatPage() { ); const handleClarificationSubmit = useCallback( - (answer: string) => { + async (answer: string) => { setHasStartedConversation(true); - void sendMessage(threadId, { text: answer, files: [] }); + await stopActiveThreadBeforeClarificationReply(threadId, thread); + await sendMessage(threadId, { text: answer, files: [] }); }, - [sendMessage, threadId], + [sendMessage, thread, threadId], ); useEffect(() => { diff --git a/frontend-web/src/pages/EmbedChatPage.tsx b/frontend-web/src/pages/EmbedChatPage.tsx index 4e9b163f..ff0a0ecf 100644 --- a/frontend-web/src/pages/EmbedChatPage.tsx +++ b/frontend-web/src/pages/EmbedChatPage.tsx @@ -9,7 +9,7 @@ import { setStoredAuth } from "@/core/auth"; import { resolveEmbedChatParams } from "@/core/embed/params"; import { registerEmbedSession } from "@/core/embed/status-api"; import { useThreadSettings } from "@/core/settings"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; import { ChatBox } from "@/components/workspace/chats"; import { @@ -110,10 +110,11 @@ export default function EmbedChatPage() { }, [authReady, threadId, registerSessionForThread]); const handleClarificationSubmit = useCallback( - (answer: string) => { - void sendMessage(threadId, { text: answer, files: [] }); + async (answer: string) => { + await stopActiveThreadBeforeClarificationReply(threadId, thread); + await sendMessage(threadId, { text: answer, files: [] }); }, - [sendMessage, threadId], + [sendMessage, thread, threadId], ); useEffect(() => { diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index cd329bd0..cc6a010c 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -40,7 +40,7 @@ import { useThreadSettings, } from "@/core/settings"; import { usePluginManagementSettings } from "@/core/system-settings/hooks"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -336,11 +336,12 @@ export default function IframeChatPage() { ); const handleClarificationSubmit = useCallback( - (answer: string) => { + async (answer: string) => { setHasStartedConversation(true); - void sendMessage(threadId, { text: answer, files: [] }); + await stopActiveThreadBeforeClarificationReply(threadId, thread); + await sendMessage(threadId, { text: answer, files: [] }); }, - [sendMessage, threadId], + [sendMessage, thread, threadId], ); const handleAgentChange = useCallback((agentId: string | null) => { diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 82c42559..f5547f2f 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -53,7 +53,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import { isAgentSkillCandidate } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; import { cn } from "@/lib/utils"; @@ -335,13 +335,25 @@ export default function NewAgentPage() { // Chat is purely a discussion channel. The agent record is only // created when the user explicitly clicks Save (top-right). If the // model proposes a config via setup_agent, our approval handler - // prefills the form locally 鈥?it does NOT persist anything. + // prefills the form locally — it does NOT persist anything. await ensureThreadExists(); await sendMessage(threadId, { text: trimmed, files: [] }); }, [ensureThreadExists, sendMessage, thread.isLoading, threadId], ); + const handleClarificationSubmit = useCallback( + async (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + // 协助卡提交不能因 isLoading 直接丢弃——弱模型出卡后可能仍在流式。 + await stopActiveThreadBeforeClarificationReply(threadId, thread); + await ensureThreadExists(); + await sendMessage(threadId, { text: trimmed, files: [] }); + }, + [ensureThreadExists, sendMessage, thread, threadId], + ); + const handleApproveSetupAgent = useCallback( async (payload: SetupAgentApprovalPayload) => { // Approving the model's setup_agent proposal **does not** persist @@ -425,7 +437,7 @@ export default function NewAgentPage() { className="size-full" threadId={threadId} thread={thread} - onClarificationSubmit={handleChatSubmit} + onClarificationSubmit={handleClarificationSubmit} onSetupAgentApprove={handleApproveSetupAgent} onSetupAgentReject={handleRejectSetupAgent} /> diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index b05dde15..e64aef96 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -25,6 +25,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks"; +import { stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; import { fetchDocumentByChunk } from "@/core/studio/api"; import type { DocumentWithChunks } from "@/core/studio/api"; import type { SourceItem } from "@/core/studio/types"; @@ -381,12 +382,13 @@ export default function StudioNotebookDetailPage({ // Clarification 卡片的「发送」按钮回调。MessageList 内部把用户选中的选项 + // 自由文本合并成一段 answer 传过来,这里走和普通发送同一条 send() 链路, // 把当前选中的来源也一并带上,保持上下文一致。 + // 弱模型出卡后可能仍在流式:先 stop 再发,不能因 isStreaming 直接丢弃。 const handleClarificationSubmit = useCallback( async (answer: string) => { - if (isStreaming) return; - await send(answer, selectedSourceIds); + await stopActiveThreadBeforeClarificationReply(chatThreadId, thread); + await send(answer, selectedSourceIds, { fromClarification: true }); }, - [send, selectedSourceIds, isStreaming], + [send, selectedSourceIds, chatThreadId, thread], ); const handleBackToList = () => { diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index ac44ce33..18fdf1b1 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -87,7 +87,11 @@ import { useLocalSettings } from "@/core/settings/hooks"; import { useModels } from "@/core/models/hooks"; import type { DocumentRewriteStreamEvent, DocumentRewriteSummary } from "@/open-canvas/api/rewrite"; import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; -import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; +import { + stopActiveThreadBeforeClarificationReply, + stopThreadRun, + useThreadStream, +} from "@/core/threads/hooks"; import { extractContentFromMessage, extractReasoningContentFromMessage, @@ -947,9 +951,11 @@ function DeepResearchWorkbenchInner() { const handleClarificationSubmit = useCallback( async (text: string) => { + const threadId = session?.runtimeThreadId; + await stopActiveThreadBeforeClarificationReply(threadId, collectorThread); await sendCollectorMessage(text); }, - [sendCollectorMessage], + [collectorThread, sendCollectorMessage, session?.runtimeThreadId], ); // The very first research topic: auto-sent into the freshly pre-built diff --git a/frontend-web/src/pages/deep-research/ReportStructureSetupChat.tsx b/frontend-web/src/pages/deep-research/ReportStructureSetupChat.tsx index ebe90a58..d61fee52 100644 --- a/frontend-web/src/pages/deep-research/ReportStructureSetupChat.tsx +++ b/frontend-web/src/pages/deep-research/ReportStructureSetupChat.tsx @@ -28,7 +28,7 @@ import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolC import { getAPIClient } from "@/core/api"; import { useModels } from "@/core/models/hooks"; import { useSkills } from "@/core/skills/hooks"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; import { cn } from "@/lib/utils"; @@ -79,7 +79,10 @@ export function ReportStructureSetupChat({ const ensureThreadExists = useCallback(async () => { if (threadPrepared) return; - await getAPIClient().threads.create({ threadId, metadata: { thread_type: "report_structure_setup" } }); + await getAPIClient().threads.create({ + threadId, + metadata: { thread_type: "report_structure_setup", system: true }, + }); setThreadPrepared(true); }, [threadId, threadPrepared]); @@ -99,15 +102,9 @@ export function ReportStructureSetupChat({ const handleClarificationSubmit = useCallback( async (text: string) => { - if (thread.isLoading) { - try { - await Promise.resolve(thread.stop()); - } catch { - /* best-effort: still send the answer */ - } - } const trimmed = text.trim(); if (!trimmed) return; + await stopActiveThreadBeforeClarificationReply(threadId, thread); await ensureThreadExists(); await sendMessage(threadId, { text: trimmed, files: [] }); }, diff --git a/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx b/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx index 7d76d5ff..09be8c0a 100644 --- a/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx +++ b/frontend-web/src/pages/deep-research/WorkflowConfigDialog.tsx @@ -12,33 +12,33 @@ * custom_outline),弹窗切换为展示 标题 + 接口内容,右上角「返回」回列表。 */ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; import { - BookOpen, - CheckCircle2, - ChevronLeft, + Check, + Clock, Compass, Copy, Database, + Eye, FileText, Info, - ListTree, + Layers, Loader2, Pencil, - PencilLine, Plus, + RotateCcw, Search, + ShieldCheck, + SlidersHorizontal, + Tag, Trash2, + User, Workflow, X, - type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -49,7 +49,6 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, @@ -57,7 +56,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { type ReportStructure, @@ -99,166 +97,132 @@ function creatorLabel(structure: ReportStructure): string { return structure.createdBy.split("@")[0] || structure.createdBy; } -/** - * 大纲常写成「每行一条、中间没有空行」,markdown 会合成一段。 - * 把单个换行转成硬换行,空行仍作为段落分隔。 - */ -function outlineToMarkdown(content: string): string { - return content.replace(/\r\n/g, "\n").replace(/([^\n])\n(?!\n)/g, "$1 \n"); +function isSystemStructure(structure: ReportStructure): boolean { + const creator = creatorLabel(structure); + return creator === "系统内置" || creator.toLowerCase() === "system"; } -function textFromChildren(children: ReactNode): string { - if (typeof children === "string" || typeof children === "number") return String(children); - if (Array.isArray(children)) return children.map(textFromChildren).join(""); - return ""; -} - -function outlineParagraphClassName(children: ReactNode): string { - const text = textFromChildren(children).trim(); - - if (/^《.+》$/.test(text)) { - return "my-5 border-b border-primary/25 pb-4 text-xl font-semibold tracking-tight text-foreground first:mt-0"; - } - if (/^(日期|类型|密级)\s*[::]/.test(text)) { - return "my-2 rounded-lg border border-border/80 bg-muted/40 px-3.5 py-2 text-base font-medium leading-7 text-foreground"; - } - if (/^第[一二三四五六七八九十百千万零〇\d]+章/.test(text)) { - return "mb-3 mt-8 border-l-4 !border-l-primary bg-primary/5 px-4 py-3 text-xl font-semibold leading-7 text-foreground first:mt-0"; - } - if (/^\d+(?:\.\d+)+\s*/.test(text)) { - return "mb-2 mt-6 border-b border-border/70 pb-2 text-lg font-semibold leading-7 text-foreground"; - } - return "my-2 text-base leading-8 whitespace-pre-wrap text-foreground"; -} - -const structureMarkdownComponents = { - img: () => null, - p: ({ children }: { children?: ReactNode }) => ( -

{children}

- ), - ul: ({ children }: { children?: ReactNode }) => ( -
    {children}
- ), - ol: ({ children }: { children?: ReactNode }) => ( -
    {children}
- ), - li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , - h1: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h2: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h3: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - h4: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), - strong: ({ children }: { children?: ReactNode }) => ( - {children} - ), - em: ({ children }: { children?: ReactNode }) => {children}, - blockquote: ({ children }: { children?: ReactNode }) => ( -
    {children}
    - ), - a: ({ children, href }: { children?: ReactNode; href?: string }) => ( - - {children} - - ), - table: ({ children }: { children?: ReactNode }) => ( -
    - {children}
    -
    - ), - th: ({ children }: { children?: ReactNode }) => ( - {children} - ), - td: ({ children }: { children?: ReactNode }) => ( - {children} - ), - hr: () =>
    , - code: ({ children, className }: { children?: ReactNode; className?: string }) => { - const isBlock = /language-/.test(className ?? ""); - if (isBlock) { - return ( -
    -          {children}
    -        
    - ); - } - return ( - {children} - ); - }, +type OutlinePreviewLine = { + label?: string; + content: string; }; -function DetailSidebarSection({ - icon: Icon, - title, - action, - children, -}: { - icon: LucideIcon; +type OutlinePreviewSection = { + number: string; title: string; - action?: ReactNode; - children: ReactNode; -}) { - return ( -
    -
    - -

    {title}

    - {action &&
    {action}
    } -
    -
    {children}
    -
    - ); + lines: OutlinePreviewLine[]; +}; + +function cleanOutlineText(line: string): string { + return line + .trim() + .replace(/^#{1,6}\s*/, "") + .replace(/^\s*[-*+•]\s+/, "") + .replace(/\*\*/g, "") + .replace(/`/g, "") + .trim(); } -function DetailSidebarTitle({ title }: { title: string }) { - return ( -
    -
    - - 报告标题 -
    -

    {title}

    -
    - ); +function extractMajorSection(text: string, fallbackNumber: number): { number: string; title: string } | null { + const decimalSubsection = /^\d+\.\d+/.test(text); + if (decimalSubsection) return null; + + const arabic = text.match(/^(\d+)[..、]\s*(.+)$/); + if (arabic) return { number: arabic[1], title: arabic[2].trim() }; + + const chineseOrder = text.match(/^([一二三四五六七八九十]+)[..、]\s*(.+)$/); + if (chineseOrder) return { number: String(fallbackNumber), title: chineseOrder[2].trim() }; + + const chapter = text.match(/^第[一二三四五六七八九十百千万〇零\d]+[章节篇部][::\s]*(.+)$/); + if (chapter) return { number: String(fallbackNumber), title: chapter[1].trim() }; + + return null; } -function DetailModeBadge({ value }: { value: string }) { - return ( - - - {value} - - ); +function parseOutlineLine(text: string): OutlinePreviewLine { + const withoutSubsectionNumber = text.replace(/^\d+(?:\.\d+)+\s*/, "").trim(); + const colonIndex = withoutSubsectionNumber.search(/[::]/); + if (colonIndex > 0 && colonIndex <= 18) { + return { + label: withoutSubsectionNumber.slice(0, colonIndex).trim(), + content: withoutSubsectionNumber.slice(colonIndex + 1).trim(), + }; + } + return { content: withoutSubsectionNumber }; } -function DetailInfoIcon({ icon: Icon }: { icon: LucideIcon }) { - return ( -
    - -
    - ); +function extractOutlineDocumentTitle(content: string, fallbackTitle: string): string { + for (const rawLine of content.replace(/\r\n/g, "\n").split("\n")) { + const raw = rawLine.trim(); + if (!raw) continue; + + const heading = raw.match(/^(#{1,6})\s+(.+)$/); + const text = cleanOutlineText(heading ? heading[2] : raw); + if (!text || text === "报告标题") continue; + if (/^(日期|类型|密级)\s*[::]/.test(text)) continue; + if (extractMajorSection(text, 1) || /^\d+\.\d+/.test(text)) break; + + const wrapped = text.match(/^《(.+)》$/); + if (wrapped?.[1]) return wrapped[1].trim(); + if (/报告/.test(text)) return text; + break; + } + + return fallbackTitle; } -function DetailTagList({ items }: { items: string[] }) { - return ( -
    - {items.map((item) => ( - - {item} - - ))} -
    - ); +function isLeadingDocumentTitle(text: string): boolean { + return /^《.+》$/.test(text) || /报告/.test(text); +} + +function parseOutlinePreview(content: string, documentTitle: string, structureTitle: string): OutlinePreviewSection[] { + const sections: OutlinePreviewSection[] = []; + let current: OutlinePreviewSection | null = null; + let nextNumber = 1; + + for (const rawLine of content.replace(/\r\n/g, "\n").split("\n")) { + const raw = rawLine.trim(); + if (!raw) continue; + + const heading = raw.match(/^(#{1,6})\s+(.+)$/); + const headingLevel = heading ? heading[1].length : null; + const text = cleanOutlineText(heading ? heading[2] : raw); + if (!text) continue; + if (text === documentTitle || text === structureTitle || text === "报告标题") continue; + if (/^(日期|类型|密级)\s*[::]/.test(text)) continue; + + const major = extractMajorSection(text, nextNumber); + const isHeadingMajor = headingLevel !== null && headingLevel <= 2 && !/^\d+\.\d+/.test(text); + if (!current && sections.length === 0 && !major && isLeadingDocumentTitle(text)) continue; + + if (major || isHeadingMajor) { + current = { + number: major?.number ?? String(nextNumber), + title: major?.title ?? text, + lines: [], + }; + sections.push(current); + nextNumber += 1; + continue; + } + + const line = parseOutlineLine(text); + if (!current) { + current = { + number: String(nextNumber), + title: line.label ?? line.content, + lines: line.label ? [{ content: line.content }] : [], + }; + sections.push(current); + nextNumber += 1; + continue; + } + current.lines.push(line); + } + + if (sections.length > 0) return sections; + const fallback = cleanOutlineText(content).slice(0, 600); + return [{ number: "1", title: documentTitle || structureTitle, lines: fallback ? [{ content: fallback }] : [] }]; } // ── main dialog ────────────────────────────────────────────────────────────── @@ -277,8 +241,10 @@ export function WorkflowConfigDialog({ onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; }) { const [statusFilter, setStatusFilter] = useState("all"); + const [modeFilter, setModeFilter] = useState<"all" | ReportStructureMode>("all"); const [titleFilter, setTitleFilter] = useState(""); const [detailId, setDetailId] = useState(null); + const [copiedOutlineId, setCopiedOutlineId] = useState(null); const [editorTarget, setEditorTarget] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); @@ -291,15 +257,23 @@ export function WorkflowConfigDialog({ // 重新打开时回到列表视图。 useEffect(() => { - if (open) setDetailId(null); + if (open) { + setDetailId(null); + setCopiedOutlineId(null); + } }, [open]); const structures = listQuery.data ?? []; const visible = useMemo(() => { const needle = titleFilter.trim().toLowerCase(); - if (!needle) return structures; - return structures.filter((s) => s.title.toLowerCase().includes(needle)); - }, [structures, titleFilter]); + return structures.filter((s) => { + if (modeFilter !== "all" && s.structureMode !== modeFilter) return false; + if (!needle) return true; + return [s.title, s.name, ...s.retrievalDirections].some((item) => + item.toLowerCase().includes(needle), + ); + }); + }, [modeFilter, structures, titleFilter]); const appliedStructureId = useMemo(() => { const trimmed = (appliedContent ?? "").trim(); @@ -311,13 +285,61 @@ export function WorkflowConfigDialog({ () => structures.find((s) => s.id === detailId) ?? null, [structures, detailId], ); + const detailOutlineTitle = useMemo( + () => (detail ? extractOutlineDocumentTitle(detail.content, detail.title) : ""), + [detail], + ); + const detailOutlineSections = useMemo( + () => (detail ? parseOutlinePreview(detail.content, detailOutlineTitle, detail.title) : []), + [detail, detailOutlineTitle], + ); const handleSelect = (structure: ReportStructure) => { + if (!structure.enabled) { + toast.error(`「${structure.title}」已停用,请先启用后再选用`); + return; + } onApply?.(structure.content, structure.structureMode, structure.retrievalDirections, structure.retrievalSkills); setDetailId(structure.id); toast.success(`已选择报告结构「${structure.title}」,并填充到报告结构配置`); }; + const handleDuplicate = async (structure: ReportStructure) => { + try { + const nextTitle = `${structure.title} (副本)`.slice(0, 256); + await mutations.create.mutateAsync({ + title: nextTitle, + name: structure.name, + content: structure.content, + structureMode: structure.structureMode, + retrievalDirections: structure.retrievalDirections, + retrievalSkills: structure.retrievalSkills, + }); + toast.success(`已复制报告结构「${nextTitle}」`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "复制失败"); + } + }; + + const handleCopyOutline = async () => { + if (!detail) return; + try { + await navigator.clipboard.writeText(detail.content); + setCopiedOutlineId(detail.id); + window.setTimeout(() => { + setCopiedOutlineId((current) => (current === detail.id ? null : current)); + }, 2000); + toast.success("大纲已复制"); + } catch { + toast.error("复制失败,请检查浏览器剪贴板权限"); + } + }; + + const handleBackToList = () => { + setDetailId(null); + setCopiedOutlineId(null); + }; + const handleToggleEnabled = (structure: ReportStructure, enabled: boolean) => { mutations.toggleEnabled.mutate( { id: structure.id, enabled }, @@ -340,237 +362,425 @@ export function WorkflowConfigDialog({ } }; - const handleCopyMarkdown = async () => { - if (!detail) return; - try { - await navigator.clipboard.writeText(detail.content); - toast.success("Markdown 已复制"); - } catch { - toast.error("复制失败,请检查浏览器剪贴板权限"); - } - }; - return ( <> - {/* header:列表视图=标题+筛选+新增;详情视图=结构标题,右上角「返回」 */} + {/* header:列表视图=标题+筛选+新增;详情视图=结构标题 + 返回结构库 */} {!detail ? ( -
    -
    -
    -

    工作流配置 · 报告结构

    -

    + <> +

    +
    +
    +
    +

    + 工作流配置 · 报告结构 +

    +
    +

    点击卡片选择报告结构(自动填充到报告结构配置);可启停、编辑、删除。

    -
    -
    - - setTitleFilter(event.target.value)} - placeholder="按标题筛选" - className="h-8 pl-8 text-xs" - /> + +
    +
    +
    + + setTitleFilter(event.target.value)} + placeholder="按标题或检索词筛选..." + className="h-10 rounded-xl border-slate-200/90 bg-slate-50/60 pr-8 pl-9 text-xs text-slate-800 shadow-2xs placeholder:text-slate-400 focus-visible:border-[#F5222D] focus-visible:ring-[#F5222D]/20 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 sm:text-sm" + /> + {titleFilter && ( + + )} +
    + +
    + + +
    + +
    + + +
    - -
    - {visible.length} 套 + +
    + + {visible.length} 套 +
    -
    + ) : ( -
    -
    -
    - +
    +
    +
    +
    -
    -

    {detail.title}

    +
    +

    + {detail.title} +

    {detail.id === appliedStructureId && ( - - + + 当前使用 - + + )} + {!detail.enabled && ( + + 已停用 + )} - {!detail.enabled && 已停用}
    -

    报告结构详情与信息概览

    +

    + 报告结构详情与信息概览 +

    -
    - - - -
    - + 返回结构库 + +
    )} {/* body */} {!detail ? ( - -
    - {listQuery.isLoading ? ( -
    - 加载中… -
    - ) : listQuery.isError ? ( -
    - {(listQuery.error as Error)?.message || "加载报告结构失败"} -
    - ) : visible.length === 0 ? ( -
    - - {titleFilter || statusFilter !== "all" ? "没有符合条件的报告结构" : "暂无报告结构,点击右上角「新增结构」创建"} -
    - ) : ( -
    - {visible.map((structure) => ( - handleSelect(structure)} - onEdit={(event) => { - event.stopPropagation(); - setEditorTarget(structure); - setEditorOpen(true); + <> +
    +
    + {listQuery.isLoading ? ( +
    + 加载中… +
    + ) : listQuery.isError ? ( +
    + {(listQuery.error as Error)?.message || "加载报告结构失败"} +
    + ) : visible.length === 0 ? ( +
    +
    + +
    +

    + 未找到匹配的报告结构 +

    +

    + 尝试更改搜索关键词或重置筛选状态 +

    + +
    + ) : ( +
    + {visible.map((structure) => ( + handleSelect(structure)} + onPreview={(event) => { + event.stopPropagation(); + setDetailId(structure.id); + }} + onDuplicate={(event) => { + event.stopPropagation(); + void handleDuplicate(structure); + }} + onEdit={(event) => { + event.stopPropagation(); + setEditorTarget(structure); + setEditorOpen(true); + }} + onDelete={(event) => { + event.stopPropagation(); + setDeleteTarget(structure); + }} + onToggleEnabled={(enabled) => handleToggleEnabled(structure, enabled)} + toggling={mutations.toggleEnabled.isPending || mutations.create.isPending} + /> + ))} +
    + )} +
    +
    + +
    +
    + 当前已选用: + {appliedStructureId ? ( + + + + {structures.find((item) => item.id === appliedStructureId)?.title ?? "当前结构"} + ({reportStructureModeLabel(structures.find((item) => item.id === appliedStructureId)?.structureMode ?? "adaptive")}) + + + ) : ( + 未选择 + )} +
    + + +
    + + ) : ( +
    + + +
    +
    +
    +
    + +
    + + 报告大纲预览 + +
    + +
    + + + + {reportStructureModeLabel(detail.structureMode)} + +
    +
    + +
    +
    +

    + {detailOutlineTitle} +

    + + 共 {detailOutlineSections.length} 个主要章节 + +
    + +
    + {detailOutlineSections.map((section) => ( +
    +
    + + {section.number} + +

    + {section.title} +

    +
    + + {section.lines.length > 0 && ( +
    + {section.lines.map((line, index) => ( +
    + {line.label ? ( + <> + + {line.label}: + + {line.content} + + ) : ( + {line.content} + )} +
    + ))} +
    + )} +
    ))}
    - )} -
    - - ) : ( - -
    - - -
    -
    -
    - -
    -
    -

    报告大纲

    -

    以下结构将作为研究报告的撰写框架

    -
    -
    -
    - - {outlineToMarkdown(detail.content)} - -
    -
    -
    -
    +
    + +
    )}
    @@ -628,6 +838,8 @@ function StructureCard({ structure, applied, onOpen, + onPreview, + onDuplicate, onEdit, onDelete, onToggleEnabled, @@ -636,11 +848,16 @@ function StructureCard({ structure: ReportStructure; applied: boolean; onOpen: () => void; + onPreview: (event: React.MouseEvent) => void; + onDuplicate: (event: React.MouseEvent) => void; onEdit: (event: React.MouseEvent) => void; onDelete: (event: React.MouseEvent) => void; onToggleEnabled: (enabled: boolean) => void; toggling: boolean; }) { + const system = isSystemStructure(structure); + const directions = structure.retrievalDirections; + return (
    -
    -
    - {structure.title} - {applied && 当前使用} - - {reportStructureModeLabel(structure.structureMode)} - - - {structure.enabled ? "启用" : "停用"} - -
    -
    - - + + + + +
    + +

    + {structure.name || "暂无详细描述"} +

    + + {directions.length > 0 && ( +
    +
    + + 检索方向 +
    +
    + {directions.slice(0, 3).map((direction) => ( + + {direction} + + ))} + {directions.length > 3 && ( + + +{directions.length - 3} + + )} +
    +
    + )}
    - {structure.name &&

    {structure.name}

    } - {structure.retrievalDirections.length > 0 && ( -

    - 检索方向:{structure.retrievalDirections.join("、")} -

    - )} - {structure.retrievalSkills.length > 0 && ( -

    - 检索来源:{structure.retrievalSkills.join("、")} -

    - )} -
    -
    -

    最后修改:{formatDateTime(structure.updatedAt)}

    -

    创建人:{creatorLabel(structure)}

    + +
    event.stopPropagation()} + role="presentation" + > +
    + + + {formatDateTime(structure.updatedAt)} + + + {system ? : } + {creatorLabel(structure)} +
    -
    event.stopPropagation()} - role="presentation" - > - {structure.enabled ? "启用" : "停用"} - + + {structure.enabled ? "启用" : "停用"} + +
    diff --git a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx index 0cec3208..9de1e630 100644 --- a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx @@ -9,7 +9,7 @@ import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { useAgent } from "@/core/agents"; import { useLocalSettings } from "@/core/settings"; -import { useThreadStream } from "@/core/threads/hooks"; +import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; import type { AgentThreadState } from "@/core/threads/types"; import { textOfMessage } from "@/core/threads/utils"; import { uuid } from "@/core/utils/uuid"; @@ -460,7 +460,10 @@ export function PositionBuiltinAgentChat({ ); const handleSubmit = useCallback( - async (message: PromptInputMessage) => { + async ( + message: PromptInputMessage, + options?: { fromClarification?: boolean }, + ) => { if (!session) { toast.warning("请先启动业务链"); return; @@ -469,7 +472,9 @@ export function PositionBuiltinAgentChat({ toast.info(readOnlyHint || "当前阶段暂不可对话"); return; } - if (pendingSubmissionRef.current || thread.isLoading) { + if (options?.fromClarification) { + pendingSubmissionRef.current = false; + } else if (pendingSubmissionRef.current || thread.isLoading) { return; } pendingSubmissionRef.current = true; @@ -540,6 +545,8 @@ export function PositionBuiltinAgentChat({ { agent_id: submission.agentId, ...(selectedModel ? { model: selectedModel } : {}), + // Summary: one Markdown only. Action-plan needs report + JSON — no cap. + ...(kind === "summary" ? { position_artifact_cap: 1 } : {}), }, { hiddenContext: { @@ -683,9 +690,18 @@ export function PositionBuiltinAgentChat({ // Use the built-in agent's regular submit path. This keeps a // clarification answer on the same persisted thread and retains // the summary/action-plan preparation and completion lifecycle. - onClarificationSubmit={(answer) => - handleSubmit({ text: answer, files: [] }) - } + onClarificationSubmit={(answer) => { + void (async () => { + await stopActiveThreadBeforeClarificationReply( + boundThreadId ?? displayThreadId, + thread, + ); + await handleSubmit( + { text: answer, files: [] }, + { fromClarification: true }, + ); + })(); + }} onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onOpenArtifact={handleOpenMessageArtifact} /> diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index b2d63d94..633c67c7 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -16,6 +16,7 @@ import { MessageList } from "@/components/workspace/messages"; import { useAgent } from "@/core/agents"; import { useLocalSettings } from "@/core/settings"; import { + stopActiveThreadBeforeClarificationReply, useEditMessage, useThreadStream, type ToolStartEvent, @@ -852,6 +853,9 @@ export function PositionNodeChat({ await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, { agent_id: node.agentId, task_external_context: taskExternalContext, + // Hard-cap outputs write_file to one deliverable per turn (weak models + // otherwise rewrite the same Markdown and loop). Action-plan is exempt. + position_artifact_cap: 1, }); } catch (error) { autoPreviewRunArmedRef.current = false; @@ -1006,9 +1010,15 @@ export function PositionNodeChat({ // nodes own the actual stream. Route their answer through the // same submit path as the normal input so handoff context, // permissions and node lifecycle updates are all preserved. - onClarificationSubmit={(answer) => - handleSubmit({ text: answer, files: [] }) - } + onClarificationSubmit={(answer) => { + void (async () => { + await stopActiveThreadBeforeClarificationReply( + boundThreadId ?? displayThreadId, + thread, + ); + await handleSubmit({ text: answer, files: [] }); + })(); + }} onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onEditSave={handleSaveMessageEdit} messageContentOverrides={messageContentOverrides} diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index a130b10a..cde9acf0 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -76,6 +76,26 @@ @custom-variant dark (&:is(.dark *)); +@layer utilities { + .report-structure-detail-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .report-structure-detail-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .report-structure-detail-scrollbar::-webkit-scrollbar-thumb { + background: #e2e8f0; + border-radius: 9999px; + } + + .report-structure-detail-scrollbar::-webkit-scrollbar-thumb:hover { + background: #cbd5e1; + } +} + @theme { --font-sans: "Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC", "思源黑体", diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 92b72eca..af56914d 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -531,6 +531,16 @@ Built-in summary and action-plan completion also preserves a visible report answer when the model misses its final `write_file` call: the router writes that exact answer as the required Markdown artifact in the owning thread's `outputs/` directory, so users receive both chat text and a downloadable file. +**Per-turn outputs write cap (弱模型防重复产物)**: ordinary position-node chats +and the summary builtin send run-context `position_artifact_cap: 1` (whitelisted +in `services.merge_run_context_overrides`). `PositionArtifactCapMiddleware` +then keeps at most one successful `/mnt/user-data/outputs/` `write_file` per +user turn — truncates excess tool calls in the same model response (and syncs +raw provider `additional_kwargs.tool_calls`), rejects further writes after an +`OK` or an earlier sibling in the same batch, ends the turn when only excess +writes remain, and reminds the model to `present_files` / wrap up. **Action-plan +does not send the flag** (it must write report `.md` + +`action-plan-subtasks.json`). Tests: `tests/test_position_artifact_cap.py`. ### 岗位 (Position) Subscription System diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index 7f201f35..18b9a21c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -659,7 +659,8 @@ def _build_node_context_text( lines.extend( [ "- 本席位最终只需交付一份 Markdown 报告文档。", - "- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。", + "- 请在本轮用 `write_file` **只写一次** `/mnt/user-data/outputs/` 下的单个 `.md` 文件,然后调用 `present_files` 呈现该文件并简短收尾。", + "- **禁止**在同一轮再调用 `write_file` 生成第二份(或内容相同的)产物;写完一份后立即结束文件写入。", "- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。", "- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。", ] @@ -1108,7 +1109,8 @@ async def _build_builtin_prompt( if kind == "summary": instructions = [ "请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。", - "必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。", + "必须使用 write_file **只写一次** `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。", + "**禁止**再次 write_file 生成第二份(或相同)报告;写完后 present_files 并简短收尾即可。", "报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。", ] else: diff --git a/offline-backend-20260512/backend/app/gateway/routers/threads.py b/offline-backend-20260512/backend/app/gateway/routers/threads.py index cfc1bb56..7f5ce9d9 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/threads.py +++ b/offline-backend-20260512/backend/app/gateway/routers/threads.py @@ -703,11 +703,12 @@ 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. + # System threads (scheduler runs, roundtable workers, report-structure / + # writing setup wizards, …) carry ``metadata.system=true`` and/or a known + # ``thread_type`` — 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, diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index fb73c7da..b814c8e2 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -363,6 +363,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset( "task_external_context", # 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。 "roundtable_peer_deliveries", + # 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。 + "position_artifact_cap", # 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后 # 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。 "summarization_enabled", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index b5c20756..83e78c1e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -368,6 +368,13 @@ def _build_middlewares( middlewares.append(SkillReviewMiddleware()) + # PositionArtifactCapMiddleware — position-roundtable seats (and summary) + # may only emit N outputs write_file calls per user turn. Action-plan skips + # the context flag so it can still write report + JSON. + from deerflow.agents.middlewares.position_artifact_cap_middleware import PositionArtifactCapMiddleware + + middlewares.append(PositionArtifactCapMiddleware()) + # LoopDetectionMiddleware — detect and break repetitive tool call loops middlewares.append(LoopDetectionMiddleware()) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py index 1195a9aa..f4214f92 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py @@ -11,7 +11,6 @@ from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware, hook_config from langchain_core.messages import AIMessage, ToolMessage -from langgraph.graph import END from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.types import Command @@ -155,47 +154,30 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): "allow_multiple": resolve_allow_multiple(args), } - def _handle_clarification(self, request: ToolCallRequest) -> Command: - """Handle clarification request and return command to interrupt execution. + def _parse_tool_args(self, tool_call: Any) -> dict[str, Any]: + args = self._tool_field(tool_call, "args") or {} + if isinstance(args, dict): + return args + if isinstance(args, str): + try: + parsed = json.loads(args) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, TypeError): + return {} + return {} - Args: - request: Tool call request + def _handle_clarification(self, request: ToolCallRequest) -> ToolMessage: + """Return the clarification ToolMessage without Command(goto=END). - Returns: - Command that interrupts execution with the formatted clarification message + Stopping the turn is ``after_model`` + ``jump_to=end``. Returning a + Command here used to mix with parallel tool results and loop the graph. + If the tools node still runs (jump_to not honored), a ToolMessage with + the same stable id replaces rather than appends. """ - # Extract clarification arguments - args = request.tool_call.get("args", {}) - question = args.get("question", "") - - logger.info("Intercepted clarification request") - logger.debug("Clarification question: %s", question) - - # Format the clarification message - formatted_message = self._format_clarification_message(args) - - # Get the tool call ID - tool_call_id = request.tool_call.get("id", "") - - # Create a ToolMessage with the formatted question - # This will be added to the message history - tool_message = ToolMessage( - id=self._stable_message_id(tool_call_id, formatted_message), - content=formatted_message, - tool_call_id=tool_call_id, - name="ask_clarification", - additional_kwargs={"clarification": self._build_payload(args)}, - ) - - # Return a Command that: - # 1. Adds the formatted tool message - # 2. Interrupts execution by going to __end__ - # Note: We don't add an extra AIMessage here - the frontend will detect - # and display ask_clarification tool messages directly - return Command( - update={"messages": [tool_message]}, - goto=END, - ) + args = self._parse_tool_args(request.tool_call) + tool_call_id = str(self._tool_field(request.tool_call, "id") or "") + logger.info("Intercepted clarification request in wrap_tool_call") + return self._clarification_tool_message(tool_call_id, args) def _tool_field(self, tool_call: Any, key: str, default: Any = None) -> Any: if isinstance(tool_call, dict): @@ -205,6 +187,21 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): return getter(key, default) return getattr(tool_call, key, default) + def _tool_name(self, tool_call: Any) -> str: + """Normalize tool names from dict / object / OpenAI-style nested function.""" + raw = self._tool_field(tool_call, "name") + if raw is None and isinstance(tool_call, dict): + fn = tool_call.get("function") + if isinstance(fn, dict): + raw = fn.get("name") + name = str(raw or "").strip() + if "." in name: + name = name.rsplit(".", 1)[-1] + return name + + def _is_clarification_call(self, tool_call: Any) -> bool: + return self._tool_name(tool_call).casefold() == "ask_clarification" + def _clarification_tool_message(self, tool_call_id: str, args: dict[str, Any]) -> ToolMessage: formatted_message = self._format_clarification_message(args) return ToolMessage( @@ -218,6 +215,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): def _after_model_intercept(self, last: AIMessage) -> dict[str, Any] | None: """Stop the run as soon as the model emits ask_clarification. + Applies to **all** lead-agent chats (通用问答 / 智能体 / 写作配置 / 报告结构…)。 + ``wrap_tool_call`` + ``Command(goto=END)`` is unreliable with parallel tool calls (weak models often emit two clarifications, or clarification plus another tool): the Command mixes with other tool results and the @@ -225,20 +224,16 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): as "the user still hasn't answered" and keeps generating. """ tool_calls = list(last.tool_calls or []) - clarification_calls = [ - tc for tc in tool_calls if self._tool_field(tc, "name") == "ask_clarification" - ] + clarification_calls = [tc for tc in tool_calls if self._is_clarification_call(tc)] if not clarification_calls: return None messages: list[ToolMessage] = [] for tc in tool_calls: - name = str(self._tool_field(tc, "name") or "") + name = self._tool_name(tc) or "unknown" tool_call_id = str(self._tool_field(tc, "id") or "") - args = self._tool_field(tc, "args") or {} - if not isinstance(args, dict): - args = {} - if name == "ask_clarification": + args = self._parse_tool_args(tc) + if self._is_clarification_call(tc): messages.append(self._clarification_tool_message(tool_call_id, args)) else: messages.append( @@ -246,7 +241,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): id=f"clarification-skip:{tool_call_id or name}", content="已暂停:需要先等待用户回答澄清问题,本工具未执行。", tool_call_id=tool_call_id, - name=name or "unknown", + name=name, ) ) @@ -280,27 +275,50 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): ) -> dict[str, Any] | None: return self.after_model(state, runtime) + @override + def _stub_sibling_tool(self, request: ToolCallRequest) -> ToolMessage: + name = self._tool_name(request.tool_call) or "unknown" + tool_call_id = str(self._tool_field(request.tool_call, "id") or "") + return ToolMessage( + id=f"clarification-skip:{tool_call_id or name}", + content="已暂停:需要先等待用户回答澄清问题,本工具未执行。", + tool_call_id=tool_call_id, + name=name, + ) + + def _request_has_sibling_clarification(self, request: ToolCallRequest) -> bool: + state = getattr(request, "state", None) + messages: Any = None + if isinstance(state, dict): + messages = state.get("messages") + elif state is not None: + getter = getattr(state, "get", None) + if callable(getter): + messages = getter("messages") + else: + messages = getattr(state, "messages", None) + if not messages: + return False + last = messages[-1] + tool_calls = getattr(last, "tool_calls", None) or [] + return any(self._is_clarification_call(tc) for tc in tool_calls) + @override def wrap_tool_call( self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: - """Intercept ask_clarification tool calls and interrupt execution (sync version). + """Complete clarification tool calls; do not Command(goto=END). - Args: - request: Tool call request - handler: Original tool execution handler - - Returns: - Command that interrupts execution with the formatted clarification message + Turn-stop is ``after_model`` + ``jump_to=end``. If the tools node still + runs, return a ToolMessage so parallel tools do not mix Commands. """ - # Check if this is an ask_clarification tool call - if request.tool_call.get("name") != "ask_clarification": - # Not a clarification call, execute normally - return handler(request) - - return self._handle_clarification(request) + if self._is_clarification_call(request.tool_call): + return self._handle_clarification(request) + if self._request_has_sibling_clarification(request): + return self._stub_sibling_tool(request) + return handler(request) @override async def awrap_tool_call( @@ -308,18 +326,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]): request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: - """Intercept ask_clarification tool calls and interrupt execution (async version). - - Args: - request: Tool call request - handler: Original tool execution handler (async) - - Returns: - Command that interrupts execution with the formatted clarification message - """ - # Check if this is an ask_clarification tool call - if request.tool_call.get("name") != "ask_clarification": - # Not a clarification call, execute normally - return await handler(request) - - return self._handle_clarification(request) + if self._is_clarification_call(request.tool_call): + return self._handle_clarification(request) + if self._request_has_sibling_clarification(request): + return self._stub_sibling_tool(request) + return await handler(request) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/position_artifact_cap_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/position_artifact_cap_middleware.py new file mode 100644 index 00000000..6c861a3f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/position_artifact_cap_middleware.py @@ -0,0 +1,376 @@ +"""Cap ``write_file`` deliveries for position-roundtable seats. + +Weak models (especially after a first successful Markdown write) often call +``write_file`` again with another copy of the same report and loop until the +recursion limit kills the run. + +When the run context carries ``position_artifact_cap`` (set by the position- +roundtable UI for ordinary seats and the summary agent — **not** for +action-plan, which must emit report + JSON), this middleware: + +1. Truncates excess outputs ``write_file`` tool calls in a single model + response (keeps only the first *N*), including raw provider metadata. +2. Rejects further outputs ``write_file`` calls once *N* successful *or* + earlier in-batch writes already exist in the current user turn (so + parallel tool calls cannot race past the cap). +3. Ends the turn when the only remaining calls were excess writes. +4. Injects a brief hidden reminder before the next model call so the agent + wraps up with ``present_files`` / a short reply instead of rewriting. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Callable +from copy import deepcopy +from typing import Any, override + +from langchain.agents import AgentState +from langchain.agents.middleware import AgentMiddleware, hook_config +from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse +from langchain_core.messages import HumanMessage, ToolMessage +from langgraph.prebuilt.tool_node import ToolCallRequest +from langgraph.runtime import Runtime +from langgraph.types import Command + +logger = logging.getLogger(__name__) + +_CONTEXT_KEY = "position_artifact_cap" +_OUTPUTS_PREFIX = "/mnt/user-data/outputs" +_WRITE_TOOL = "write_file" + +_REJECT_MSG = ( + "本席位本轮产物上限已达:outputs 下只允许写入一份交付文件。" + "请勿再次调用 write_file;若尚未呈现文件请调用 present_files," + "然后用简短文字说明交付已完成。" +) + +_REMINDER = ( + "【系统提示】本席位本轮产物文件已成功写入 outputs。" + "禁止再次调用 write_file 生成第二份(或相同)产物。" + "请立即 present_files(如尚未呈现),并给出简短收尾回复后结束本轮。" +) + + +def _normalize_args(raw_args: object) -> dict[str, Any]: + if isinstance(raw_args, dict): + return raw_args + if isinstance(raw_args, str): + try: + parsed = json.loads(raw_args) + except (TypeError, ValueError, json.JSONDecodeError): + return {} + return parsed if isinstance(parsed, dict) else {} + return {} + + +def _tool_field(tool_call: Any, key: str, default: Any = None) -> Any: + if isinstance(tool_call, dict): + return tool_call.get(key, default) + getter = getattr(tool_call, "get", None) + if callable(getter): + return getter(key, default) + return getattr(tool_call, key, default) + + +def _is_outputs_path(path: object) -> bool: + text = str(path or "").strip().replace("\\", "/") + if not text: + return False + if text == _OUTPUTS_PREFIX or text.startswith(f"{_OUTPUTS_PREFIX}/"): + return True + lowered = text.lstrip("./") + return lowered == "outputs" or lowered.startswith("outputs/") + + +def _tool_call_writes_outputs(tool_call: Any) -> bool: + if _tool_field(tool_call, "name") != _WRITE_TOOL: + return False + args = _normalize_args(_tool_field(tool_call, "args")) + return _is_outputs_path(args.get("path")) + + +def _cap_from_mapping(mapping: object) -> int | None: + if not isinstance(mapping, dict): + return None + raw = mapping.get(_CONTEXT_KEY) + if raw is None or raw is False: + return None + try: + value = int(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + +def _cap_from_runtime(runtime: object | None) -> int | None: + if runtime is None: + return None + context = getattr(runtime, "context", None) + cap = _cap_from_mapping(context) + if cap is not None: + return cap + config = getattr(runtime, "config", None) + if not isinstance(config, dict): + return None + for bucket_key in ("configurable", "metadata", "context"): + cap = _cap_from_mapping(config.get(bucket_key)) + if cap is not None: + return cap + return None + + +def _is_visible_human(message: Any) -> bool: + if getattr(message, "type", None) != "human": + return False + kwargs = getattr(message, "additional_kwargs", None) or {} + if kwargs.get("hide_from_ui"): + return False + name = getattr(message, "name", None) + if name in {"loop_warning", "position_artifact_cap"}: + return False + return True + + +def _current_turn_start(messages: list[Any]) -> int: + for index in range(len(messages) - 1, -1, -1): + if _is_visible_human(messages[index]): + return index + return 0 + + +def _successful_outputs_writes(messages: list[Any], *, turn_start: int) -> int: + """Count successful outputs ``write_file`` calls in the current user turn.""" + outputs_ids: set[str] = set() + success = 0 + for message in messages[turn_start:]: + msg_type = getattr(message, "type", None) + if msg_type == "ai": + for tool_call in getattr(message, "tool_calls", None) or []: + if not _tool_call_writes_outputs(tool_call): + continue + call_id = _tool_field(tool_call, "id") + if isinstance(call_id, str) and call_id: + outputs_ids.add(call_id) + continue + if msg_type != "tool": + continue + call_id = getattr(message, "tool_call_id", None) + if not isinstance(call_id, str) or call_id not in outputs_ids: + continue + content = str(getattr(message, "content", "") or "").strip() + if content == "OK" or (content and not content.startswith("Error")): + success += 1 + return success + + +def _kept_call_ids(tool_calls: list[Any]) -> set[str]: + ids: set[str] = set() + for tool_call in tool_calls: + call_id = _tool_field(tool_call, "id") + if isinstance(call_id, str) and call_id: + ids.add(call_id) + return ids + + +def _build_truncated_update(last_msg: Any, kept: list[Any]) -> dict[str, Any]: + """Replace structured tool_calls and keep raw provider metadata in sync.""" + update: dict[str, Any] = {"tool_calls": kept} + additional_kwargs = dict(getattr(last_msg, "additional_kwargs", {}) or {}) + raw_calls = additional_kwargs.get("tool_calls") + kept_ids = _kept_call_ids(kept) + if isinstance(raw_calls, list) and raw_calls: + if not kept: + additional_kwargs.pop("tool_calls", None) + additional_kwargs.pop("function_call", None) + else: + filtered = [item for item in raw_calls if isinstance(item, dict) and item.get("id") in kept_ids] + if filtered: + additional_kwargs["tool_calls"] = filtered + else: + additional_kwargs.pop("tool_calls", None) + additional_kwargs.pop("function_call", None) + elif not kept: + additional_kwargs.pop("tool_calls", None) + additional_kwargs.pop("function_call", None) + update["additional_kwargs"] = additional_kwargs + if not kept: + response_metadata = deepcopy(getattr(last_msg, "response_metadata", {}) or {}) + if response_metadata.get("finish_reason") == "tool_calls": + response_metadata["finish_reason"] = "stop" + update["response_metadata"] = response_metadata + return update + + +def _outputs_budget_used(messages: list[Any], *, turn_start: int, current_id: str | None) -> int: + """Successful writes this turn, plus earlier sibling writes in the same AI batch.""" + used = _successful_outputs_writes(messages, turn_start=turn_start) + if not current_id: + return used + last_ai = None + for message in reversed(messages): + if getattr(message, "type", None) == "ai": + last_ai = message + break + if last_ai is None: + return used + batch = list(getattr(last_ai, "tool_calls", None) or []) + if current_id not in {str(_tool_field(tool_call, "id") or "") for tool_call in batch}: + return used + for tool_call in batch: + call_id = str(_tool_field(tool_call, "id") or "") + if call_id == current_id: + break + if _tool_call_writes_outputs(tool_call): + used += 1 + return used + + +class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]): + """Enforce a per-turn outputs ``write_file`` budget for position roundtable.""" + + state_schema = AgentState + + def _truncate_excess_writes(self, state: AgentState, runtime: Runtime) -> dict | None: + cap = _cap_from_runtime(runtime) + if cap is None: + return None + + messages = list(state.get("messages") or []) + if not messages: + return None + + last_msg = messages[-1] + if getattr(last_msg, "type", None) != "ai": + return None + + tool_calls = list(getattr(last_msg, "tool_calls", None) or []) + if not tool_calls: + return None + + already = _successful_outputs_writes(messages[:-1], turn_start=_current_turn_start(messages)) + remaining = max(0, cap - already) + + kept: list[Any] = [] + dropped = 0 + kept_outputs = 0 + for tool_call in tool_calls: + if _tool_call_writes_outputs(tool_call): + if kept_outputs >= remaining: + dropped += 1 + continue + kept_outputs += 1 + kept.append(tool_call) + + if dropped == 0: + return None + + logger.warning( + "Position artifact cap truncated %s excess write_file call(s) (cap=%s, already=%s)", + dropped, + cap, + already, + ) + updated = last_msg.model_copy(update=_build_truncated_update(last_msg, kept)) + result: dict[str, Any] = {"messages": [updated]} + if not kept: + # No remaining tools — stop instead of looping back to the model + # which would just emit another copy of write_file. + result["jump_to"] = "end" + return result + + def _reject_over_cap( + self, + request: ToolCallRequest, + ) -> ToolMessage | None: + tool_call = getattr(request, "tool_call", None) or {} + if not _tool_call_writes_outputs(tool_call): + return None + + runtime = getattr(request, "runtime", None) + cap = _cap_from_runtime(runtime) + if cap is None: + return None + + state = getattr(request, "state", None) or {} + messages = list(state.get("messages") or []) if isinstance(state, dict) else [] + current_id = str(_tool_field(tool_call, "id") or "") + used = _outputs_budget_used(messages, turn_start=_current_turn_start(messages), current_id=current_id or None) + if used < cap: + return None + + logger.warning( + "Position artifact cap rejected write_file (cap=%s, used=%s, id=%s)", + cap, + used, + current_id, + ) + return ToolMessage( + content=f"Error: {_REJECT_MSG}", + tool_call_id=current_id, + name=_WRITE_TOOL, + ) + + def _maybe_remind(self, request: ModelRequest) -> ModelRequest: + cap = _cap_from_runtime(getattr(request, "runtime", None)) + if cap is None: + return request + messages = list(getattr(request, "messages", None) or []) + already = _successful_outputs_writes(messages, turn_start=_current_turn_start(messages)) + if already < cap: + return request + reminder = HumanMessage( + content=_REMINDER, + name="position_artifact_cap", + additional_kwargs={"hide_from_ui": True, "lc_source": "position_artifact_cap"}, + ) + return request.override(messages=[*messages, reminder]) + + @hook_config(can_jump_to=["end"]) + @override + def after_model(self, state: AgentState, runtime: Runtime) -> dict | None: + return self._truncate_excess_writes(state, runtime) + + @hook_config(can_jump_to=["end"]) + @override + async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None: + return self._truncate_excess_writes(state, runtime) + + @override + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command], + ) -> ToolMessage | Command: + rejected = self._reject_over_cap(request) + if rejected is not None: + return rejected + return handler(request) + + @override + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]], + ) -> ToolMessage | Command: + rejected = self._reject_over_cap(request) + if rejected is not None: + return rejected + return await handler(request) + + @override + def wrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], ModelResponse], + ) -> ModelCallResult: + return handler(self._maybe_remind(request)) + + @override + async def awrap_model_call( + self, + request: ModelRequest, + handler: Callable[[ModelRequest], Awaitable[ModelResponse]], + ) -> ModelCallResult: + return await handler(self._maybe_remind(request)) 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 a45afe33..f439e1b9 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 @@ -15,9 +15,33 @@ three-state semantics (see :mod:`deerflow.runtime.user_context`): from __future__ import annotations import abc +from typing import Any from deerflow.runtime.user_context import AUTO, _AutoSentinel +# Configuration wizards / machine workers that must not appear in the default +# conversation list. Prefer ``metadata.system=true`` on create; this set also +# covers older rows that only set ``thread_type``. +EXCLUDED_SYSTEM_THREAD_TYPES = frozenset( + { + "scheduler", + "roundtable", + "notebook", + "deep_research", + "writing_setup", + "report_structure_setup", + } +) + + +def is_excluded_system_thread(metadata: dict[str, Any] | None) -> bool: + """Return True when a thread should be hidden from user conversation lists.""" + md = metadata or {} + if md.get("system"): + return True + thread_type = md.get("thread_type") + return isinstance(thread_type, str) and thread_type in EXCLUDED_SYSTEM_THREAD_TYPES + class ThreadMetaStore(abc.ABC): @abc.abstractmethod @@ -56,9 +80,9 @@ class ThreadMetaStore(abc.ABC): ) -> 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. + ``exclude_system=True`` drops machine / wizard threads (``metadata.system`` + or known ``thread_type`` values such as scheduler / report_structure_setup) + so user-facing conversation lists are not crowded out. ``query`` is a case-insensitive substring match on the thread title (``display_name``). ``limit``/``offset`` apply to the *filtered* result set. 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 f5fa8614..26030406 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 @@ -11,7 +11,7 @@ from typing import Any from langgraph.store.base import BaseStore -from deerflow.persistence.thread_meta.base import ThreadMetaStore +from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id from deerflow.utils.time import coerce_iso, now_iso @@ -107,7 +107,7 @@ class MemoryThreadMetaStore(ThreadMetaStore): # 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"): + if exclude_system and is_excluded_system_thread(record.get("metadata")): return False if normalized_query and normalized_query not in (record.get("display_name") or "").lower(): return False @@ -169,7 +169,7 @@ class MemoryThreadMetaStore(ThreadMetaStore): break for item in items: record = self._item_to_dict(item) - if exclude_system and (record.get("metadata") or {}).get("system"): + if exclude_system and is_excluded_system_thread(record.get("metadata")): continue if normalized_query and normalized_query not in (record.get("display_name") or "").lower(): continue 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 bb2a93b0..ab144b2c 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 @@ -8,7 +8,7 @@ from typing import Any from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from deerflow.persistence.thread_meta.base import ThreadMetaStore +from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id @@ -166,7 +166,7 @@ class ThreadMetaRepository(ThreadMetaStore): 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"): + if exclude_system and is_excluded_system_thread(md): return False return True @@ -233,7 +233,7 @@ class ThreadMetaRepository(ThreadMetaStore): md = metadata_json or {} if metadata and not all(md.get(k) == v for k, v in metadata.items()): continue - if exclude_system and md.get("system"): + if exclude_system and is_excluded_system_thread(md): continue total += 1 if len(rows) < batch_size: