diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 554254f4..643809a0 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -59,6 +59,9 @@ # 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 # 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # VITE_COMPACT_MENU_TYPE=A +# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。 +# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。 +# VITE_TURBO_MODE_VISIBLE=false # VITE_GITHUB_OAUTH_TOKEN= # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 6d6109ad..58a9a428 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = { // GitHub OAuth Token;不使用 GitHub 相关能力时留空。 VITE_GITHUB_OAUTH_TOKEN: "", + // 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。 + // 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。 + // 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。 + VITE_TURBO_MODE_VISIBLE: "true", + // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 VITE_SENTIMENT_AGENT_VISIBLE: "true", diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 3deab8dc..48c13068 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -7,6 +7,7 @@ import { CheckIcon, FileTextIcon, FolderTreeIcon, + GaugeIcon, GraduationCapIcon, LightbulbIcon, PaperclipIcon, @@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; import type { Model } from "@/core/models/types"; import type { AgentThreadContext } from "@/core/threads"; +import { isTurboModeVisible } from "@/core/threads/use-direct-chat"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -93,14 +95,14 @@ import { import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useUserPreferences } from "@/core/user-preferences"; -import { - isThreadRagModeLocked, - lockThreadRagMode, - useLocalSettings, -} from "@/core/settings"; -import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; - -import { CustomPromptBar } from "./custom-prompt-menu"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; +import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; + +import { CustomPromptBar } from "./custom-prompt-menu"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { Tooltip } from "./tooltip"; @@ -223,16 +225,20 @@ export function InputBox({ context.execution_profile === "single_round_rag"; const multiRoundRagActive = context.execution_profile === "multi_round_rag"; + const turboModeVisible = isTurboModeVisible(); + const directChatActive = + turboModeVisible && context.execution_profile === "direct"; const frontendRagActive = singleRoundRagActive || multiRoundRagActive; + const bypassAgentActive = frontendRagActive || directChatActive; const modelOptions = useMemo(() => { return singleRoundRagActive ? extraModels : models; }, [extraModels, models, singleRoundRagActive]); - const { thread, isMock } = useThread(); - const { textInput } = usePromptInputController(); - const promptRootRef = useRef(null); - const { data: citationDisplay } = useCitationDisplaySettings(); - const referenceModeEntryEnabled = - citationDisplay?.reference_mode_enabled ?? false; + const { thread, isMock } = useThread(); + const { textInput } = usePromptInputController(); + const promptRootRef = useRef(null); + const { data: citationDisplay } = useCitationDisplaySettings(); + const referenceModeEntryEnabled = + citationDisplay?.reference_mode_enabled ?? false; const [followups, setFollowups] = useState([]); const [followupsHidden, setFollowupsHidden] = useState(false); @@ -253,11 +259,37 @@ export function InputBox({ recommendedQuestions.length > 0; useEffect(() => { - // Multi-round RAG is a frontend execution profile, not a model option. - // Do not let the normal model fallback clear it on every render. + // Frontend-only execution profiles are not model options. + // Do not let the normal model fallback clear them on every render. + if (!turboModeVisible && context.execution_profile === "direct") { + onContextChange?.({ + ...context, + execution_profile: undefined, + }); + return; + } if (multiRoundRagActive) { return; } + if (directChatActive) { + if (models.length === 0) { + return; + } + const currentModel = models.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? models[0]!; + if ( + context.model_name === fallbackModel.name && + context.execution_profile === "direct" + ) { + return; + } + onContextChange?.({ + ...context, + model_name: fallbackModel.name, + execution_profile: "direct", + }); + return; + } if (modelOptions.length === 0) { return; } @@ -281,7 +313,7 @@ export function InputBox({ mode: nextMode, execution_profile: fallbackModel.execution_profile, }); - }, [context, modelOptions, multiRoundRagActive, onContextChange]); + }, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]); const selectedModel = useMemo(() => { if (modelOptions.length === 0) { @@ -314,7 +346,10 @@ export function InputBox({ model_name, mode: getResolvedMode(context.mode, model.supports_thinking ?? false), reasoning_effort: context.reasoning_effort, - execution_profile: model.execution_profile, + execution_profile: + context.execution_profile === "direct" + ? "direct" + : model.execution_profile, }); setModelDialogOpen(false); }, @@ -346,6 +381,16 @@ export function InputBox({ [onContextChange, context, models, supportThinking], ); + const handleDirectChatSelect = useCallback(() => { + const ordinaryModel = + models.find((model) => model.name === context.model_name) ?? models[0]; + onContextChange?.({ + ...context, + model_name: ordinaryModel?.name ?? context.model_name, + execution_profile: "direct", + }); + }, [context, models, onContextChange]); + const handleSingleRoundRagSelect = useCallback(() => { const ragModel = extraModels.find((model) => model.name === context.model_name) ?? @@ -749,7 +794,7 @@ export function InputBox({ /> */} - {!frontendRagActive && ( + {!bypassAgentActive && ( )} - {!frontendRagActive && !hideContextTools && 写作 } - {!frontendRagActive && 文件 } - {!frontendRagActive && !hideContextTools && !hideMemoryTool && 记忆 } - {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && 参考文献 } - {!frontendRagActive &&
- {frontendRagActive ? ( + {directChatActive ? ( + + ) : frontendRagActive ? ( ) : context.mode === "flash" ? ( @@ -948,10 +999,14 @@ export function InputBox({
- {frontendRagActive + {directChatActive + ? t.inputBox.turboMode + : frontendRagActive ? multiRoundRagActive ? "多轮 RAG" : "单轮 RAG" @@ -969,9 +1024,39 @@ export function InputBox({ {t.inputBox.mode} + {turboModeVisible && ( +
+
+ + {t.inputBox.turboMode} +
+
+ {t.inputBox.turboModeDescription} +
+
+ {directChatActive ? ( + + ) : ( +
+ )} + + )} + @@ -992,7 +1077,7 @@ export function InputBox({ {t.inputBox.flashModeDescription}
- {!frontendRagActive && context.mode === "flash" ? ( + {!bypassAgentActive && context.mode === "flash" ? ( ) : (
@@ -1001,7 +1086,7 @@ export function InputBox({ {supportThinking && ( @@ -1022,7 +1107,7 @@ export function InputBox({ {t.inputBox.reasoningModeDescription}
- {!frontendRagActive && context.mode === "thinking" ? ( + {!bypassAgentActive && context.mode === "thinking" ? ( ) : (
@@ -1031,7 +1116,7 @@ export function InputBox({ )} @@ -1052,7 +1137,7 @@ export function InputBox({ {t.inputBox.proModeDescription}
- {!frontendRagActive && context.mode === "pro" ? ( + {!bypassAgentActive && context.mode === "pro" ? ( ) : (
@@ -1060,7 +1145,7 @@ export function InputBox({
- {!frontendRagActive && context.mode === "ultra" ? ( + {!bypassAgentActive && context.mode === "ultra" ? ( ) : (
@@ -1161,7 +1246,7 @@ export function InputBox({ - {!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && ( + {!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
@@ -1279,7 +1364,7 @@ export function InputBox({ )} - {!frontendRagActive && agentSelector && ( + {!bypassAgentActive && agentSelector && ( )} - {!frontendRagActive && knowledgeSelector} - {!frontendRagActive && promptPrefix && ( + {!bypassAgentActive && knowledgeSelector} + {!bypassAgentActive && promptPrefix && ( threadId); // Ref to track current thread ID across async callbacks without causing re-renders, @@ -1507,21 +1507,21 @@ export function useThreadStream({ ...context, ...extraContext, }; - const selectedLlmWikiIds = Array.isArray( - mergedContext.llmwiki_knowledge_base_ids, - ) - ? mergedContext.llmwiki_knowledge_base_ids - : []; - const selectedAssistantKnowledgeIds = Array.isArray( - mergedContext.assistant_knowledge_base_ids, - ) - ? mergedContext.assistant_knowledge_base_ids - : []; - const ragModeEnabled = - (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || - isThreadRagModeLocked(threadId) || - selectedLlmWikiIds.length > 0 || - selectedAssistantKnowledgeIds.length > 0; + const selectedLlmWikiIds = Array.isArray( + mergedContext.llmwiki_knowledge_base_ids, + ) + ? mergedContext.llmwiki_knowledge_base_ids + : []; + const selectedAssistantKnowledgeIds = Array.isArray( + mergedContext.assistant_knowledge_base_ids, + ) + ? mergedContext.assistant_knowledge_base_ids + : []; + const ragModeEnabled = + (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || + isThreadRagModeLocked(threadId) || + selectedLlmWikiIds.length > 0 || + selectedAssistantKnowledgeIds.length > 0; if (ragModeEnabled) { lockThreadRagMode(threadId); } @@ -1579,6 +1579,7 @@ export function useThreadStream({ context: { ...mergedContext, thinking_enabled: mergedContext.mode !== "flash", + thinking_force_disabled: mergedContext.mode === "flash", is_plan_mode: mergedContext.mode === "pro" || mergedContext.mode === "ultra", subagent_enabled: mergedContext.mode === "ultra", @@ -1639,18 +1640,18 @@ export function useThreadStream({ sendInFlightRef.current = false; } }, - [ - thread, - _handleOnStart, - t.uploads.uploadingFiles, - context, - referenceModeEntryEnabled, - localSettings.display.ragModeEnabled, - queryClient, - runOptions, - confirmOrRejoin, - ], - ); + [ + thread, + _handleOnStart, + t.uploads.uploadingFiles, + context, + referenceModeEntryEnabled, + localSettings.display.ragModeEnabled, + queryClient, + runOptions, + confirmOrRejoin, + ], + ); const handleThreadStop = useCallback(async () => { const tid = threadIdRef.current; @@ -1699,7 +1700,7 @@ export function useThreadStream({ ? undefined : thread.error; - const mergedThread = { + const mergedThread = { ...thread, error: visibleThreadError, values: mergedValues, @@ -1709,7 +1710,7 @@ export function useThreadStream({ : mergedMessages, isLoading: thread.isLoading || streamHoldOpen, stop: handleThreadStop, - } as typeof thread; + } as typeof thread; // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 diff --git a/frontend-web/src/core/threads/types.ts b/frontend-web/src/core/threads/types.ts index c1b606b7..e3a7247e 100644 --- a/frontend-web/src/core/threads/types.ts +++ b/frontend-web/src/core/threads/types.ts @@ -2,18 +2,20 @@ import type { Message, Thread } from "@langchain/langgraph-sdk"; import type { Todo } from "../todos"; -export interface AgentThreadState extends Record { - title: string; - title_provisional?: boolean; - messages: Message[]; - artifacts: string[]; - todos?: Todo[]; -} +export interface AgentThreadState extends Record { + title: string; + title_provisional?: boolean; + messages: Message[]; + artifacts: string[]; + todos?: Todo[]; +} export interface AgentThreadContext extends Record { thread_id: string; model_name: string | undefined; thinking_enabled: boolean; + /** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */ + thinking_force_disabled?: boolean; is_plan_mode: boolean; subagent_enabled: boolean; reasoning_effort?: "minimal" | "low" | "medium" | "high"; @@ -23,15 +25,15 @@ export interface AgentThreadContext extends Record { rag_mode_enabled?: boolean; /** Per-request switch for context compression (上下文压缩). Default off. */ summarization_enabled?: boolean; - /** Per-conversation switch for knowledge-base RAG injection (phase 2). */ - knowledge_rag_enabled?: boolean; - /** DeerFlow LLMWiki mapping ids selected for this conversation. */ - llmwiki_knowledge_base_ids?: string[]; - /** Assistant knowledge-base ids selected for this conversation. */ - assistant_knowledge_base_ids?: string[]; - /** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */ - execution_profile?: "single_round_rag" | "multi_round_rag"; - agent_id?: string; + /** Per-conversation switch for knowledge-base RAG injection (phase 2). */ + knowledge_rag_enabled?: boolean; + /** DeerFlow LLMWiki mapping ids selected for this conversation. */ + llmwiki_knowledge_base_ids?: string[]; + /** Assistant knowledge-base ids selected for this conversation. */ + assistant_knowledge_base_ids?: string[]; + /** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */ + execution_profile?: "single_round_rag" | "multi_round_rag" | "direct"; + agent_id?: string; /** @deprecated kept for reading legacy thread metadata; prefer agent_id */ agent_name?: string; } diff --git a/frontend-web/src/core/threads/use-direct-chat.ts b/frontend-web/src/core/threads/use-direct-chat.ts new file mode 100644 index 00000000..30c1677a --- /dev/null +++ b/frontend-web/src/core/threads/use-direct-chat.ts @@ -0,0 +1,312 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import { readRuntimeFlag } from "@/env"; +import { createClientId } from "@/lib/client-id"; + +import { textOfMessage } from "./utils"; + +const STORAGE_PREFIX = "workspace.direct-chat."; +const MAX_HISTORY_MESSAGES = 20; + +export function isDirectChatProfile(context: unknown): boolean { + return ( + typeof context === "object" && + context !== null && + (context as { execution_profile?: unknown }).execution_profile === "direct" + ); +} + +/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */ +export function isTurboModeVisible(): boolean { + return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE"); +} + +function storageKey(threadId: string) { + return `${STORAGE_PREFIX}${threadId}`; +} + +function readStoredMessages(threadId: string): Message[] { + if (typeof window === "undefined" || !threadId) return []; + try { + const raw = window.sessionStorage.getItem(storageKey(threadId)); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? (parsed as Message[]) : []; + } catch { + return []; + } +} + +function writeStoredMessages(threadId: string, messages: Message[]) { + if (typeof window === "undefined" || !threadId) return; + try { + window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages)); + } catch { + // Quota errors must never interrupt an already-running answer. + } +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +} + +function historyFromMessages(messages: Message[]): Array<{ + role: "user" | "assistant"; + content: string; +}> { + const history: Array<{ role: "user" | "assistant"; content: string }> = []; + for (const message of messages) { + if (message.type !== "human" && message.type !== "ai") continue; + const content = textOfMessage(message)?.trim(); + if (!content) continue; + history.push({ + role: message.type === "human" ? "user" : "assistant", + content, + }); + } + return history.slice(-MAX_HISTORY_MESSAGES); +} + +function parseSseBlocks(buffer: string): { + events: Array<{ event?: string; data: string }>; + rest: string; +} { + const parts = buffer.split("\n\n"); + const rest = parts.pop() ?? ""; + const events: Array<{ event?: string; data: string }> = []; + for (const part of parts) { + let event: string | undefined; + const dataLines: string[] = []; + for (const line of part.split("\n")) { + if (line.startsWith(":")) continue; + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + } + if (dataLines.length > 0) { + events.push({ event, data: dataLines.join("\n") }); + } + } + return { events, rest }; +} + +async function readErrorDetail(response: Response): Promise { + try { + const payload: unknown = await response.json(); + if ( + payload && + typeof payload === "object" && + "detail" in payload && + typeof (payload as { detail: unknown }).detail === "string" + ) { + return (payload as { detail: string }).detail; + } + } catch { + // Fall through to status text. + } + return `极速问答失败(${response.status})`; +} + +/** + * Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``. + * It never submits a LangGraph run or touches the lead-agent stack. + */ +export function useDirectChatMessages(threadId: string | undefined) { + const [messages, setMessages] = useState(() => + readStoredMessages(threadId ?? ""), + ); + const messagesRef = useRef(messages); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const controllerRef = useRef(null); + + useEffect(() => { + const stored = readStoredMessages(threadId ?? ""); + messagesRef.current = stored; + setMessages(stored); + setError(null); + }, [threadId]); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => () => controllerRef.current?.abort(), []); + + const updateMessages = useCallback( + (targetThreadId: string, updater: (current: Message[]) => Message[]) => { + setMessages((current) => { + const next = updater(current); + messagesRef.current = next; + writeStoredMessages(targetThreadId, next); + return next; + }); + }, + [], + ); + + const stop = useCallback(() => controllerRef.current?.abort(), []); + + const submit = useCallback( + async ({ + targetThreadId, + query, + modelName, + }: { + targetThreadId: string; + query: string; + modelName?: string; + }) => { + if (isLoading) return; + const question = query.trim(); + if (!question) return; + + const humanId = createClientId("direct-human"); + const assistantId = createClientId("direct-ai"); + const clientTs = new Date().toISOString(); + const history = historyFromMessages(messagesRef.current); + const userMessage: Message = { + id: humanId, + type: "human", + content: [{ type: "text", text: question }], + additional_kwargs: { client_ts: clientTs, direct_chat: true }, + }; + const initialAssistant: Message = { + id: assistantId, + type: "ai", + content: "", + additional_kwargs: { direct_chat: true }, + }; + updateMessages(targetThreadId, (current) => [ + ...current, + userMessage, + initialAssistant, + ]); + setError(null); + setIsLoading(true); + + const controller = new AbortController(); + controllerRef.current = controller; + let answer = ""; + + const refreshAssistant = (content: string) => { + updateMessages(targetThreadId, (current) => + current.map((item) => + item.id === assistantId + ? ({ + ...item, + content, + additional_kwargs: { + ...item.additional_kwargs, + direct_chat: true, + }, + } as Message) + : item, + ), + ); + }; + + try { + const response = await apiFetch( + `${getBackendBaseURL()}/api/direct-chat/stream`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + model_name: modelName, + messages: [...history, { role: "user", content: question }], + }), + }, + ); + if (!response.ok) { + throw new Error(await readErrorDetail(response)); + } + if (!response.body) { + throw new Error("极速问答未返回数据流。"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parsed = parseSseBlocks(buffer); + buffer = parsed.rest; + for (const block of parsed.events) { + let payload: unknown = null; + try { + payload = JSON.parse(block.data); + } catch { + continue; + } + if (block.event === "error") { + const detail = + payload && + typeof payload === "object" && + "detail" in payload && + typeof (payload as { detail: unknown }).detail === "string" + ? (payload as { detail: string }).detail + : "极速问答失败。"; + throw new Error(detail); + } + if ( + block.event === "delta" && + payload && + typeof payload === "object" && + "text" in payload && + typeof (payload as { text: unknown }).text === "string" + ) { + answer += (payload as { text: string }).text; + refreshAssistant(answer); + } + if ( + block.event === "result" && + payload && + typeof payload === "object" && + "answer" in payload && + typeof (payload as { answer: unknown }).answer === "string" + ) { + answer = (payload as { answer: string }).answer; + refreshAssistant(answer); + } + } + } + if (!answer) { + throw new Error("模型未返回任何文本。"); + } + } catch (caught) { + if (!isAbortError(caught)) { + const message = + caught instanceof Error ? caught.message : "极速问答失败。"; + setError(message); + updateMessages(targetThreadId, (current) => + current.map((item) => + item.id === assistantId + ? ({ + ...item, + content: answer || message, + additional_kwargs: { + ...item.additional_kwargs, + direct_chat: true, + }, + } as Message) + : item, + ), + ); + } + } finally { + if (controllerRef.current === controller) controllerRef.current = null; + setIsLoading(false); + } + }, + [isLoading, updateMessages], + ); + + return { messages, isLoading, error, submit, stop }; +} diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index e0f73872..c511c89f 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom"; import { useRouteBase } from "@/core/route-base"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; @@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; +import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { textOfMessage } from "@/core/threads/utils"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; @@ -560,9 +562,26 @@ export default function AgentChatPage({ }, }); + const directChat = useDirectChatMessages(threadId); + const directChatSelected = + isDirectChatProfile(settings.context) && isTurboModeVisible(); + // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 流式 stub),成功且真实产物落地后自动撤出。 - const { displayThread } = useResearchWritingFeed(threadId, thread); + const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); + const displayThread = useMemo(() => { + const base = researchDisplayThread; + if (directChat.messages.length === 0 && !directChat.isLoading) { + return base; + } + const messages = [...base.messages, ...directChat.messages]; + return { + ...base, + values: { ...base.values, messages }, + messages, + isLoading: base.isLoading || directChat.isLoading, + } as typeof base; + }, [directChat.isLoading, directChat.messages, researchDisplayThread]); // ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 @@ -743,6 +762,62 @@ export default function AgentChatPage({ const handleSubmit = useCallback( (message: PromptInputMessage, extraContext?: Record) => { + if (directChatSelected) { + if (message.files && message.files.length > 0) { + toast.error("极速问答暂不支持附件,请移除附件后重试。"); + return; + } + setHasStartedConversation(true); + saveLastAgentId(agent_id); + window.dispatchEvent( + new CustomEvent("workspace:sandbox-resume-autoscroll"), + ); + void (async () => { + try { + let targetThreadId = threadId; + if (isNewThread) { + const created = await getAPIClient().threads.create({ + metadata: { + agent_id, + ...(Object.keys(conversationMetadata).length > 0 + ? conversationMetadata + : {}), + }, + }); + targetThreadId = created.thread_id; + knowledgeSelectionThread.current = targetThreadId; + activeThreadIdRef.current = targetThreadId; + setThreadId(targetThreadId); + setIsNewThread(false); + history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`, + ); + const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60); + if (title) { + void getAPIClient().threads + .updateState(targetThreadId, { values: { title } }) + .catch(() => undefined); + } + } + if (!targetThreadId) { + toast.error("无法创建会话。"); + return; + } + await directChat.submit({ + targetThreadId, + query: message.text, + modelName: settings.context.model_name, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "极速问答失败。", + ); + } + })(); + return; + } setHasStartedConversation(true); // Remember this agent so the new-chat landing screen pre-selects it next // time (covers both direct agent chats and the ChatPage handoff replay). @@ -767,7 +842,7 @@ export default function AgentChatPage({ ...pageReportThemeContext, }); }, - [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode], + [sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread], ); // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 @@ -852,8 +927,12 @@ export default function AgentChatPage({ ); const handleStop = useCallback(async () => { + if (directChat.isLoading) { + directChat.stop(); + return; + } await thread.stop(); - }, [thread]); + }, [directChat, thread]); const isLandingState = isNewThread && !hasStartedConversation; // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 @@ -867,7 +946,7 @@ export default function AgentChatPage({ const status = thread.error ? "error" - : thread.isLoading + : thread.isLoading || directChat.isLoading ? "streaming" : "ready"; @@ -1084,7 +1163,7 @@ export default function AgentChatPage({ autoFocus status={status} context={settings.context} - disabled={isDemoMode || knowledgeSelectionLoading} + disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading} onContextChange={(context) => setSettings("context", context) } @@ -1104,7 +1183,7 @@ export default function AgentChatPage({ pendingKnowledgeSelection.current === null && pendingAssistantKnowledgeSelection.current === null } - disabled={isDemoMode || knowledgeSelectionLoading} + disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading} label="知识空间" /> ) @@ -1175,7 +1254,7 @@ export default function AgentChatPage({ assistantId={agent_id} status={status} context={settings.context} - disabled={isDemoMode || knowledgeSelectionLoading} + disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading} onContextChange={(context) => setSettings("context", context) } @@ -1190,7 +1269,7 @@ export default function AgentChatPage({ assistantValue={selectedAssistantKnowledgeBaseIds} onChange={changeKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection} - disabled={isDemoMode || knowledgeSelectionLoading} + disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading} readOnly label="知识空间" /> diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 046708a2..5edcc89f 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -69,6 +69,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; +import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat"; import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag"; import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; @@ -348,9 +349,12 @@ export default function ChatPage() { }); const singleRoundRag = useSingleRoundRagMessages(threadId); const multiRoundRag = useMultiRoundRagMessages(threadId); + const directChat = useDirectChatMessages(threadId); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); const frontendRagSelected = isFrontendRagProfile(settings.context); + const directChatSelected = + isDirectChatProfile(settings.context) && isTurboModeVisible(); // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 流式 stub),成功且真实产物落地后自动撤出。 const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); @@ -360,13 +364,15 @@ export default function ChatPage() { if ( singleRoundRag.messages.length === 0 && ragMessages.length === 0 && + directChat.messages.length === 0 && !singleRoundRag.isLoading && - !multiRoundRag.isLoading + !multiRoundRag.isLoading && + !directChat.isLoading ) { return base; } const messages = mergeMessagesByTime( - [...base.messages, ...singleRoundRag.messages], + [...base.messages, ...singleRoundRag.messages, ...directChat.messages], ragMessages, ); return { @@ -374,9 +380,14 @@ export default function ChatPage() { values: { ...base.values, messages }, messages, isLoading: - base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading, + base.isLoading || + singleRoundRag.isLoading || + multiRoundRag.isLoading || + directChat.isLoading, } as typeof base; }, [ + directChat.isLoading, + directChat.messages, multiRoundRag.isLoading, multiRoundRag.messages, researchDisplayThread, @@ -392,6 +403,52 @@ export default function ChatPage() { const handleSubmit = useCallback( (message: PromptInputMessage) => { + if (directChatSelected) { + if (message.files && message.files.length > 0) { + toast.error("极速问答暂不支持附件,请移除附件后重试。"); + return; + } + setHasStartedConversation(true); + window.dispatchEvent( + new CustomEvent("workspace:sandbox-resume-autoscroll"), + ); + void (async () => { + try { + let targetThreadId = threadId; + if (isNewThread) { + const created = await getAPIClient(isMock).threads.create({}); + targetThreadId = created.thread_id; + setThreadId(targetThreadId); + setIsNewThread(false); + history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`, + ); + const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60); + if (title) { + void getAPIClient(isMock).threads + .updateState(targetThreadId, { values: { title } }) + .catch(() => undefined); + } + } + if (!targetThreadId) { + toast.error("无法创建会话。"); + return; + } + await directChat.submit({ + targetThreadId, + query: message.text, + modelName: settings.context.model_name, + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "极速问答失败。", + ); + } + })(); + return; + } if (frontendRagSelected) { if (message.files && message.files.length > 0) { toast.error("RAG 问答暂不支持附件,请移除附件后重试。"); @@ -560,6 +617,8 @@ export default function ChatPage() { multiRoundRag, multiRoundRagSelected, frontendRagSelected, + directChat, + directChatSelected, routeBase, setIsNewThread, setThreadId, @@ -655,6 +714,10 @@ export default function ChatPage() { saveLastAgentId(agentId); }, []); const handleStop = useCallback(async () => { + if (directChat.isLoading) { + directChat.stop(); + return; + } if (singleRoundRag.isLoading) { singleRoundRag.stop(); return; @@ -664,7 +727,7 @@ export default function ChatPage() { return; } await thread.stop(); - }, [multiRoundRag, singleRoundRag, thread]); + }, [directChat, multiRoundRag, singleRoundRag, thread]); const promptPrefixProp = useMemo(() => { if (!promptPrefixSettings?.enabled) return undefined; @@ -914,7 +977,8 @@ export default function ChatPage() { agentHandoffPending || knowledgeSelectionLoading || singleRoundRag.isLoading || - multiRoundRag.isLoading + multiRoundRag.isLoading || + directChat.isLoading } onContextChange={(context) => setSettings("context", context) diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 19185e58..8c1d37e4 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -42,6 +42,7 @@ from app.gateway.routers import ( compact_menu_overrides, dashboard_sessions, deep_research, + direct_chat, external_llmwiki, feedback, fixed_questions, @@ -1803,6 +1804,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式 app.include_router(open_chat.router) + # 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能 + app.include_router(direct_chat.router) + # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话) app.include_router(open_3qfx.router) diff --git a/offline-backend-20260512/backend/app/gateway/routers/direct_chat.py b/offline-backend-20260512/backend/app/gateway/routers/direct_chat.py new file mode 100644 index 00000000..af2efddc --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/direct_chat.py @@ -0,0 +1,149 @@ +"""Direct LLM chat — no lead agent, tools, skills, or memory. + +Used by the frontend 「极速」mode: stream tokens from ``create_chat_model`` +with thinking forced off so first-token latency matches a bare chat client. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import AsyncIterator +from typing import Any, Literal + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from pydantic import BaseModel, Field + +from deerflow.config import get_app_config +from deerflow.models import create_chat_model + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/direct-chat", tags=["direct-chat"]) + +_HEARTBEAT_SECONDS = 15.0 +_MAX_MESSAGES = 40 + + +class DirectChatMessage(BaseModel): + role: Literal["user", "assistant", "system"] + content: str = "" + + +class DirectChatRequest(BaseModel): + messages: list[DirectChatMessage] = Field(..., min_length=1) + model_name: str | None = None + + +def _chunk_text(chunk: Any) -> str: + content = getattr(chunk, "content", None) + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and isinstance(block.get("text"), str): + parts.append(block["text"]) + return "".join(parts) + return "" + + +def _build_lc_messages(body: DirectChatRequest) -> list[BaseMessage]: + built: list[BaseMessage] = [] + for item in body.messages[-_MAX_MESSAGES:]: + text = (item.content or "").strip() + if not text: + continue + if item.role == "user": + built.append(HumanMessage(content=text)) + elif item.role == "assistant": + built.append(AIMessage(content=text)) + else: + built.append(SystemMessage(content=text)) + if not any(isinstance(msg, HumanMessage) for msg in built): + raise HTTPException(status_code=400, detail="至少需要一条 user 消息") + return built + + +def _sse(event: str, data: Any) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +def _resolve_model_name(name: str | None) -> str: + config = get_app_config() + if not name: + if not config.models: + raise HTTPException(status_code=400, detail="未配置可用模型") + return config.models[0].name + if config.get_model_config(name) is None: + raise HTTPException(status_code=400, detail=f"模型不存在: {name}") + return name + + +async def _direct_chat_event_stream(body: DirectChatRequest, model_name: str) -> AsyncIterator[str]: + lc_messages = _build_lc_messages(body) + queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() + pieces: list[str] = [] + + async def produce() -> None: + try: + model = create_chat_model( + name=model_name, + thinking_enabled=False, + force_disable_thinking=True, + ) + async for chunk in model.astream(lc_messages): + text = _chunk_text(chunk) + if text: + pieces.append(text) + await queue.put(("delta", {"text": text})) + except Exception as exc: # noqa: BLE001 — stream errors become SSE error events + logger.exception("direct_chat 调用模型失败 (model=%s)", model_name) + await queue.put(("error", {"detail": f"模型调用失败:{exc}"})) + finally: + await queue.put(("__end__", None)) + + task = asyncio.create_task(produce()) + try: + while True: + try: + event, data = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS) + except asyncio.TimeoutError: + yield ": keepalive\n\n" + continue + if event == "__end__": + break + if event == "error": + yield _sse("error", data) + return + yield _sse(event, data) + + answer = "".join(pieces) + if not answer: + yield _sse("error", {"detail": "模型未返回任何文本。"}) + return + yield _sse("result", {"answer": answer, "model_name": model_name}) + finally: + if not task.done(): + task.cancel() + + +@router.post("/stream") +async def direct_chat_stream(body: DirectChatRequest) -> StreamingResponse: + """Stream a plain chat completion. No agent graph, tools, or skills.""" + model_name = _resolve_model_name(body.model_name) + _build_lc_messages(body) + return StreamingResponse( + _direct_chat_event_stream(body, model_name), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index aea7b078..99400036 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -33,6 +33,16 @@ concurrency: fail_open: true models: +- name: MiniMax-M2.7 + display_name: MiniMax-M2.7 + use: langchain_openai:ChatOpenAI + model: MiniMax-M2.7 + api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk + base_url: https://api.minimaxi.com/v1 + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true - name: MiniMax-M2.7-fash display_name: MiniMax-M2.7-fash use: langchain_openai:ChatOpenAI @@ -466,7 +476,7 @@ checkpointer: # 三层覆盖优先级(高->低): # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 memory: - enabled: true + enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具) version: v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) # 'v2'(USER.md/MEMORY.md + 可选 Hindsight) 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 619ad2f8..ad7e290f 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 @@ -40,77 +40,77 @@ logger = logging.getLogger(__name__) WRITING_SETUP_MAX_ROUNDS = 8 REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 -_ES_QUERY_ROUTING_OPEN_TAG = '' - -_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = { - # 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和 - # runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。 - "agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}), -} - -_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator" - -_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """ - -你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。 -不得加载、读取、检索或调用 agent_orchestration / agent-orchestration, -不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。 - -""".strip() - - -def _enforce_coordinator_only_tool_exclusions( - agent_id: str | None, - excluded_tools: list[str] | None, -) -> list[str] | None: - """Make orchestration unavailable to every agent except the fixed roundtable coordinator.""" - if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID: - return excluded_tools - effective = list(excluded_tools or []) - if "agent_orchestration" not in effective: - effective.append("agent_orchestration") - return effective - - -def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str: - """Reinforce the physical tool/skill gate in every non-coordinator system prompt.""" - if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID: - return system_prompt - return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}" - - -def _filter_available_skills_for_excluded_tools( - available_skills: list[str] | None, - excluded_tools: list[str] | None, -) -> list[str] | None: - """让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。""" - if not excluded_tools: - return available_skills - excluded = {name.strip().lower() for name in excluded_tools if name} - blocked = { - skill_name - for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items() - if tool_name in excluded - for skill_name in skill_names - } - if not blocked: - return available_skills - if available_skills is None: - # ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。 - # 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。 - try: - from deerflow.skills.storage import get_or_new_skill_storage - - available_skills = [ - skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True) - ] - except Exception: # noqa: BLE001 - logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True) - available_skills = [] - return [name for name in available_skills if name.strip().lower() not in blocked] +_ES_QUERY_ROUTING_OPEN_TAG = '' - -def _log_es_query_routing_registration( +_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = { + # 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和 + # runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。 + "agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}), +} + +_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator" + +_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """ + +你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。 +不得加载、读取、检索或调用 agent_orchestration / agent-orchestration, +不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。 + +""".strip() + + +def _enforce_coordinator_only_tool_exclusions( + agent_id: str | None, + excluded_tools: list[str] | None, +) -> list[str] | None: + """Make orchestration unavailable to every agent except the fixed roundtable coordinator.""" + if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID: + return excluded_tools + effective = list(excluded_tools or []) + if "agent_orchestration" not in effective: + effective.append("agent_orchestration") + return effective + + +def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str: + """Reinforce the physical tool/skill gate in every non-coordinator system prompt.""" + if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID: + return system_prompt + return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}" + + +def _filter_available_skills_for_excluded_tools( + available_skills: list[str] | None, + excluded_tools: list[str] | None, +) -> list[str] | None: + """让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。""" + if not excluded_tools: + return available_skills + excluded = {name.strip().lower() for name in excluded_tools if name} + blocked = { + skill_name + for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items() + if tool_name in excluded + for skill_name in skill_names + } + if not blocked: + return available_skills + if available_skills is None: + # ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。 + # 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。 + try: + from deerflow.skills.storage import get_or_new_skill_storage + + available_skills = [ + skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True) + ] + except Exception: # noqa: BLE001 + logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True) + available_skills = [] + return [name for name in available_skills if name.strip().lower() not in blocked] + + +def _log_es_query_routing_registration( *, system_prompt: str, app_config: AppConfig, @@ -401,9 +401,9 @@ def _build_middlewares( if summarization_middleware is not None: middlewares.append(summarization_middleware) - # Add TodoList middleware if plan mode is enabled - is_plan_mode = cfg.get("is_plan_mode", False) - is_bootstrap = cfg.get("is_bootstrap", False) + # Add TodoList middleware if plan mode is enabled + is_plan_mode = cfg.get("is_plan_mode", False) + is_bootstrap = cfg.get("is_bootstrap", False) is_writing_setup = cfg.get("is_writing_setup", False) is_report_structure_setup = cfg.get("is_report_structure_setup", False) todo_list_middleware = _create_todo_list_middleware(is_plan_mode) @@ -433,7 +433,9 @@ def _build_middlewares( # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs memory_injection_enabled = cfg.get("memory_injection_enabled", True) - if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral: + memory_cfg = getattr(resolved_app_config, "memory", None) + memory_globally_enabled = memory_cfg is not None and getattr(memory_cfg, "enabled", False) + if memory_globally_enabled and not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral: middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled)) # KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated @@ -574,10 +576,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # actually injected. Default off → no behavior change for normal chats. thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False)) reasoning_effort = cfg.get("reasoning_effort", None) - requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") - is_plan_mode = cfg.get("is_plan_mode", False) - disable_tools = bool(cfg.get("disable_tools", False)) - subagent_enabled = cfg.get("subagent_enabled", False) + requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") + is_plan_mode = cfg.get("is_plan_mode", False) + disable_tools = bool(cfg.get("disable_tools", False)) + subagent_enabled = cfg.get("subagent_enabled", False) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) is_bootstrap = cfg.get("is_bootstrap", False) # 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、 @@ -603,11 +605,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): if not is_writing_mode_report_path(writing_artifact_path): writing_artifact_path = None - agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) - requested_excluded_tools = _enforce_coordinator_only_tool_exclusions( - agent_id, - cfg.get("excluded_tools") or None, - ) + agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) + requested_excluded_tools = _enforce_coordinator_only_tool_exclusions( + agent_id, + cfg.get("excluded_tools") or None, + ) try: agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None @@ -656,14 +658,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills - runtime_available_skills = merge_collector_skill_allowlist( - agent_id, runtime_available_skills, runtime_extra_skills(cfg) - ) - runtime_available_skills = _filter_available_skills_for_excluded_tools( - runtime_available_skills, - requested_excluded_tools, - ) - prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None + runtime_available_skills = merge_collector_skill_allowlist( + agent_id, runtime_available_skills, runtime_extra_skills(cfg) + ) + runtime_available_skills = _filter_available_skills_for_excluded_tools( + runtime_available_skills, + requested_excluded_tools, + ) + prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None config["metadata"].update( { @@ -744,14 +746,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Special bootstrap agent with minimal prompt for initial custom agent creation flow return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config), - tools=get_available_tools( - model_name=model_name, - subagent_enabled=subagent_enabled, - is_scheduled_run=cfg.get("is_scheduled_run", False), - excluded_tools=requested_excluded_tools, - allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, - app_config=resolved_app_config, - ) + tools=get_available_tools( + model_name=model_name, + subagent_enabled=subagent_enabled, + is_scheduled_run=cfg.get("is_scheduled_run", False), + excluded_tools=requested_excluded_tools, + allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, + app_config=resolved_app_config, + ) + [setup_agent], middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), system_prompt=apply_prompt_template( @@ -771,19 +773,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Default lead agent (unchanged behavior) is_notebook_mode = bool(cfg.get("notebook_search_space_id")) notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or [] - default_tools = ( - [] - if disable_tools - else get_available_tools( - model_name=model_name, - groups=agent_config.tool_groups if agent_config else None, - subagent_enabled=subagent_enabled, - is_scheduled_run=cfg.get("is_scheduled_run", False), - excluded_tools=requested_excluded_tools, - allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, - app_config=resolved_app_config, - ) - ) + default_tools = ( + [] + if disable_tools + else get_available_tools( + model_name=model_name, + groups=agent_config.tool_groups if agent_config else None, + subagent_enabled=subagent_enabled, + is_scheduled_run=cfg.get("is_scheduled_run", False), + excluded_tools=requested_excluded_tools, + allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, + app_config=resolved_app_config, + ) + ) if agent_id == FORCED_RESEARCH_AGENT_ID: default_tools = filter_forced_research_tools(default_tools) if is_notebook_mode: @@ -825,8 +827,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): writing_mode=writing_mode, writing_artifact_path=writing_artifact_path, ) - else: - system_prompt = apply_prompt_template( + else: + system_prompt = apply_prompt_template( subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_id=agent_id, @@ -839,22 +841,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): rag_mode_enabled=rag_mode_enabled, is_notebook_mode=is_notebook_mode, notebook_source_ids=notebook_source_ids, - is_scheduled_run=bool(cfg.get("is_scheduled_run", False)), - ) - - # 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是 - # 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。 - presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower() - if presentation_theme in {"dark", "light"}: - system_prompt += ( - "\n\n【当前界面主题】\n" - f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;" - "除非用户明确要求覆盖该主题。" - ) - - system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id) - - _log_es_query_routing_registration( + is_scheduled_run=bool(cfg.get("is_scheduled_run", False)), + ) + + # 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是 + # 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。 + presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower() + if presentation_theme in {"dark", "light"}: + system_prompt += ( + "\n\n【当前界面主题】\n" + f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;" + "除非用户明确要求覆盖该主题。" + ) + + system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id) + + _log_es_query_routing_registration( system_prompt=system_prompt, app_config=resolved_app_config, agent_id=agent_id, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 976d9bbb..d55e5492 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -9,8 +9,8 @@ from functools import lru_cache from typing import TYPE_CHECKING from zoneinfo import ZoneInfo -from deerflow.config.agents_config import load_agent_soul -from deerflow.config.system_settings import load_system_settings +from deerflow.config.agents_config import load_agent_soul +from deerflow.config.system_settings import load_system_settings from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.types import Skill, SkillCategory from deerflow.subagents import get_available_subagent_names @@ -487,7 +487,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p """ -_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ +_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ 当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式: - 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。 @@ -496,19 +496,19 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ - 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。 - 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。 -""" - - -def _ordinary_qa_markdown_format_enabled() -> bool: - """Return the admin-controlled formal Markdown prompt switch.""" - try: - return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled) - except Exception: - logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True) - return False - - -SYSTEM_PROMPT_TEMPLATE = """ +""" + + +def _ordinary_qa_markdown_format_enabled() -> bool: + """Return the admin-controlled formal Markdown prompt switch.""" + try: + return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled) + except Exception: + logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True) + return False + + +SYSTEM_PROMPT_TEMPLATE = """ You are {agent_name}, an internal super agent. @@ -883,11 +883,16 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig return "" try: from deerflow.agents.memory.manager import build_memory_manager - from deerflow.config.memory_config import get_effective_memory_config + from deerflow.config.memory_config import get_effective_memory_config, get_memory_config from deerflow.runtime.user_context import get_effective_user_id + if not get_memory_config().enabled: + return "" + user_id = get_effective_user_id() config = get_effective_memory_config(user_id) + if not config.enabled or not config.injection.enabled: + return "" manager = build_memory_manager( user_id=user_id, @@ -1547,13 +1552,13 @@ When a search, knowledge, skill, or MCP tool returns a result list: if rag_mode_enabled else "" ) - ordinary_qa_format_section = ( - _ORDINARY_QA_MARKDOWN_FORMAT_SECTION - if _ordinary_qa_markdown_format_enabled() - and not is_bootstrap - and not writing_mode - and not is_notebook_mode - and not is_scheduled_run + ordinary_qa_format_section = ( + _ORDINARY_QA_MARKDOWN_FORMAT_SECTION + if _ordinary_qa_markdown_format_enabled() + and not is_bootstrap + and not writing_mode + and not is_notebook_mode + and not is_scheduled_run else "" ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/memory_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/memory_config.py index 896f94b9..5989ee1e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/memory_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/memory_config.py @@ -16,7 +16,7 @@ ============ ``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低): 1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取) - (有 per-user 记录时自动强制 enabled=True) + (仅白名单字段,不能覆盖 ``enabled``) 2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``) 3. Pydantic 默认值 @@ -254,13 +254,12 @@ def get_effective_memory_config(user_id: str) -> MemoryConfig: """返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。 优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。 - 有 per-user 记录时自动强制 enabled=True。 + ``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。 """ base = _memory_config.model_dump() recall_budget = _recall_budget_cache.get(user_id) if recall_budget is not None: base.setdefault("hindsight", {})["recall_budget"] = recall_budget - base["enabled"] = True try: return MemoryConfig(**base) except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py index af64a5e6..8626ef93 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py @@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict: return disable_kwargs +# Qwen3.8 chat templates only accept these three effort names. +_QWEN_REASONING_EFFORT = { + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "xhigh", + "xhigh": "xhigh", +} + +_OPENAI_THINKING_OFF_EXTRA_BODY = { + "enable_thinking": False, + "chat_template_kwargs": {"enable_thinking": False}, + "thinking": {"type": "disabled"}, +} + + +def _is_vllm_provider(model_use: str) -> bool: + lowered = (model_use or "").lower() + return "vllm_provider" in lowered or lowered.endswith(":vllmchatmodel") + + +def _is_qwen38_model(model_config) -> bool: + blob = f"{getattr(model_config, 'name', '')} {getattr(model_config, 'model', '')}".lower() + return "qwen3.8" in blob + + +def _should_control_openai_thinking(model_config) -> bool: + """Whether this OpenAI-compatible model needs explicit thinking on/off flags. + + Qwen3.8 (and current vLLM reasoning parsers) default to thinking at ``xhigh``. + If DeerFlow does not send ``enable_thinking: false``, the first visible answer + token waits until that hidden reasoning finishes — third-party chat UIs that + disable thinking or stream the reasoning field therefore feel much faster. + """ + if getattr(model_config, "supports_thinking", False): + return True + if _is_vllm_provider(getattr(model_config, "use", "")): + return True + return _is_qwen38_model(model_config) + + +def _map_qwen_reasoning_effort(effort: object) -> str | None: + if effort is None: + return None + return _QWEN_REASONING_EFFORT.get(str(effort).strip().lower()) + + +def _apply_openai_thinking_control( + model_config, + model_class, + model_settings_from_config: dict, + *, + thinking_enabled: bool, + reasoning_effort: object, +) -> None: + """Inject vLLM/Qwen thinking flags when the UI toggle is on or off.""" + if "extra_body" not in getattr(model_class, "model_fields", {}): + return + qwen_vllm = _is_vllm_provider(getattr(model_config, "use", "")) or _is_qwen38_model(model_config) + if thinking_enabled: + if not qwen_vllm: + return + enable_body: dict = { + "enable_thinking": True, + "chat_template_kwargs": {"enable_thinking": True}, + } + mapped_effort = _map_qwen_reasoning_effort(reasoning_effort) + if mapped_effort: + enable_body["chat_template_kwargs"]["reasoning_effort"] = mapped_effort + model_settings_from_config["extra_body"] = _deep_merge_dicts( + model_settings_from_config.get("extra_body"), + enable_body, + ) + return + if not _should_control_openai_thinking(model_config): + return + disable_body = ( + { + "enable_thinking": False, + "chat_template_kwargs": {"enable_thinking": False}, + } + if qwen_vllm + else _OPENAI_THINKING_OFF_EXTRA_BODY + ) + model_settings_from_config["extra_body"] = _deep_merge_dicts( + model_settings_from_config.get("extra_body"), + disable_body, + ) + + def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None: """Enable stream usage for OpenAI-compatible models unless explicitly configured. @@ -127,10 +217,19 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"): # Native langchain_anthropic: thinking is a direct constructor parameter model_settings_from_config["thinking"] = {"type": "disabled"} + requested_reasoning_effort = kwargs.get("reasoning_effort") if not model_config.supports_reasoning_effort: kwargs.pop("reasoning_effort", None) model_settings_from_config.pop("reasoning_effort", None) + _apply_openai_thinking_control( + model_config, + model_class, + model_settings_from_config, + thinking_enabled=thinking_enabled, + reasoning_effort=requested_reasoning_effort, + ) + # Hard override: force-disable thinking regardless of the per-model config shape. # Injected only for OpenAI-compatible classes (those declaring `extra_body`); # other providers would reject the kwarg, so they are skipped (they ignore it). @@ -139,15 +238,15 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, * # (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the # nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native # ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``. - # Only models that explicitly opt into thinking should receive vendor-specific - # disable payloads. Generic OpenAI-compatible gateways (including LiteLLM) - # may reject these otherwise-unknown fields instead of forwarding them. - if ( - force_disable_thinking - and not thinking_enabled - and model_config.supports_thinking - and "extra_body" in getattr(model_class, "model_fields", {}) - ): + # Only models that explicitly opt into thinking should receive vendor-specific + # disable payloads. Generic OpenAI-compatible gateways (including LiteLLM) + # may reject these otherwise-unknown fields instead of forwarding them. + if ( + force_disable_thinking + and not thinking_enabled + and model_config.supports_thinking + and "extra_body" in getattr(model_class, "model_fields", {}) + ): model_settings_from_config["extra_body"] = _deep_merge_dicts( model_settings_from_config.get("extra_body"), {"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}}, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py index 6dec1f5b..a15b837e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py @@ -29,11 +29,11 @@ from typing import Any from langchain_core.language_models import LanguageModelInput from langchain_core.messages import AIMessage, AIMessageChunk -from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI - -from deerflow.models.message_ordering import normalize_chat_payload_messages - +from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI + +from deerflow.models.message_ordering import normalize_chat_payload_messages + class PatchedChatOpenAI(ChatOpenAI): """ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway. @@ -89,16 +89,16 @@ class PatchedChatOpenAI(ChatOpenAI): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): _restore_tool_call_signatures(payload_msg, orig_msg) _restore_reasoning_content(payload_msg, orig_msg) - else: - # Fallback: match assistant-role entries positionally against AIMessages. - ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] - assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] - for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): - _restore_tool_call_signatures(payload_msg, ai_msg) - _restore_reasoning_content(payload_msg, ai_msg) - - normalize_chat_payload_messages(payload) - return payload + else: + # Fallback: match assistant-role entries positionally against AIMessages. + ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] + assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] + for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): + _restore_tool_call_signatures(payload_msg, ai_msg) + _restore_reasoning_content(payload_msg, ai_msg) + + normalize_chat_payload_messages(payload) + return payload def _convert_chunk_to_generation_chunk( self, @@ -208,11 +208,21 @@ def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None: def _extract_reasoning_content(payload: dict | None) -> str | None: - """Extract reasoning text from provider payload delta/message objects.""" + """Extract reasoning text from provider payload delta/message objects. + + vLLM Qwen reasoning parsers use ``reasoning_content`` on streaming deltas + and ``reasoning`` on some non-stream / older builds. Read both so the UI + can show the first thinking token instead of waiting for answer content. + """ if not isinstance(payload, dict): return None reasoning_content = payload.get("reasoning_content") - return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None + if isinstance(reasoning_content, str) and reasoning_content: + return reasoning_content + reasoning = payload.get("reasoning") + if isinstance(reasoning, str) and reasoning: + return reasoning + return None def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py index d72bfb8c..dfe2bac2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py @@ -32,11 +32,11 @@ from langchain_core.messages import ( ) from langchain_core.messages.tool import tool_call_chunk from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult -from langchain_openai import ChatOpenAI -from langchain_openai.chat_models.base import _create_usage_metadata - -from deerflow.models.message_ordering import normalize_chat_payload_messages - +from langchain_openai import ChatOpenAI +from langchain_openai.chat_models.base import _create_usage_metadata + +from deerflow.models.message_ordering import normalize_chat_payload_messages + def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: """Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``. @@ -107,6 +107,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def additional_kwargs["function_call"] = function_call reasoning = _dict.get("reasoning") + if reasoning is None: + reasoning = _dict.get("reasoning_content") if reasoning is not None: additional_kwargs["reasoning"] = reasoning reasoning_text = _reasoning_to_text(reasoning) @@ -184,14 +186,14 @@ class VllmChatModel(ChatOpenAI): for payload_msg, orig_msg in zip(payload_messages, original_messages): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): _restore_reasoning_field(payload_msg, orig_msg) - else: - ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] - assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] - for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): - _restore_reasoning_field(payload_msg, ai_msg) - - normalize_chat_payload_messages(payload) - return payload + else: + ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] + assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] + for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): + _restore_reasoning_field(payload_msg, ai_msg) + + normalize_chat_payload_messages(payload) + return payload def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult: """Preserve vLLM reasoning on non-streaming responses.""" diff --git a/offline-backend-20260512/backend/tests/test_model_management.py b/offline-backend-20260512/backend/tests/test_model_management.py index 1c6c5db5..eb69b513 100644 --- a/offline-backend-20260512/backend/tests/test_model_management.py +++ b/offline-backend-20260512/backend/tests/test_model_management.py @@ -239,3 +239,66 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup ) assert chat_model.extra_body is None + + +def _extra_body(chat_model) -> dict: + body = chat_model.extra_body or {} + return body if isinstance(body, dict) else dict(body) + + +def test_qwen38_flash_mode_disables_default_xhigh_thinking() -> None: + """Qwen3.8 defaults to thinking=xhigh; flash must send enable_thinking=false.""" + config = AppConfig.model_construct( + models=[ + ModelConfig( + name="qwen3.8-27b", + use="langchain_openai:ChatOpenAI", + model="Qwen/Qwen3.8-27B", + api_key="test-key", + base_url="http://vllm.example/v1", + supports_thinking=False, + ) + ] + ) + + chat_model = create_chat_model("qwen3.8-27b", thinking_enabled=False, app_config=config) + extra_body = _extra_body(chat_model) + + assert extra_body.get("enable_thinking") is False + assert extra_body.get("chat_template_kwargs", {}).get("enable_thinking") is False + + +def test_qwen38_thinking_mode_sends_medium_reasoning_effort() -> None: + config = AppConfig.model_construct( + models=[ + ModelConfig( + name="qwen3.8-27b", + use="langchain_openai:ChatOpenAI", + model="qwen3.8-27b", + api_key="test-key", + base_url="http://vllm.example/v1", + supports_thinking=True, + ) + ] + ) + + chat_model = create_chat_model( + "qwen3.8-27b", + thinking_enabled=True, + reasoning_effort="medium", + app_config=config, + ) + extra_body = _extra_body(chat_model) + kwargs = extra_body.get("chat_template_kwargs") or {} + + assert extra_body.get("enable_thinking") is True + assert kwargs.get("enable_thinking") is True + assert kwargs.get("reasoning_effort") == "medium" + + +def test_extract_reasoning_reads_vllm_reasoning_field() -> None: + from deerflow.models.patched_openai import _extract_reasoning_content + + assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题" + assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理" + assert _extract_reasoning_content({"content": "答案"}) is None diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 1fd87a16..372aa23d 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -264,7 +264,7 @@ ai_writing: # 三层覆盖优先级(高->低): # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 memory: - enabled: true + enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具) version: v1 provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) # builtin 始终启用,这里只决定 external 选哪个