feat(turbo): add direct LLM chat mode with runtime toggle

Add 极速 mode that streams via /api/direct-chat without the lead agent stack,
plus VITE_TURBO_MODE_VISIBLE in runtime-config.js (default off). Also disable
global memory, tighten Qwen/vLLM thinking control for flash mode, and force
thinking off on the direct-chat endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-09-09 21:58:36 +08:00
1 parent 4f7f07504a
commit d765afe2ac
23 files changed
+1295 -394

No files matched your search

+3
View File
@@ -59,6 +59,9 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 # 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=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= # VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+5
View File
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。 // GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "", VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true", VITE_SENTIMENT_AGENT_VISIBLE: "true",
@@ -7,6 +7,7 @@ import {
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
FolderTreeIcon, FolderTreeIcon,
GaugeIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
PaperclipIcon, PaperclipIcon,
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types"; import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -93,14 +95,14 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences"; import { useUserPreferences } from "@/core/user-preferences";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -223,16 +225,20 @@ export function InputBox({
context.execution_profile === "single_round_rag"; context.execution_profile === "single_round_rag";
const multiRoundRagActive = const multiRoundRagActive =
context.execution_profile === "multi_round_rag"; context.execution_profile === "multi_round_rag";
const turboModeVisible = isTurboModeVisible();
const directChatActive =
turboModeVisible && context.execution_profile === "direct";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive; const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const bypassAgentActive = frontendRagActive || directChatActive;
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models; return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]); }, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -253,11 +259,37 @@ export function InputBox({
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
useEffect(() => { useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option. // Frontend-only execution profiles are not model options.
// Do not let the normal model fallback clear it on every render. // 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) { if (multiRoundRagActive) {
return; 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) { if (modelOptions.length === 0) {
return; return;
} }
@@ -281,7 +313,7 @@ export function InputBox({
mode: nextMode, mode: nextMode,
execution_profile: fallbackModel.execution_profile, execution_profile: fallbackModel.execution_profile,
}); });
}, [context, modelOptions, multiRoundRagActive, onContextChange]); }, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
const selectedModel = useMemo(() => { const selectedModel = useMemo(() => {
if (modelOptions.length === 0) { if (modelOptions.length === 0) {
@@ -314,7 +346,10 @@ export function InputBox({
model_name, model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false), mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort, reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile, execution_profile:
context.execution_profile === "direct"
? "direct"
: model.execution_profile,
}); });
setModelDialogOpen(false); setModelDialogOpen(false);
}, },
@@ -346,6 +381,16 @@ export function InputBox({
[onContextChange, context, models, supportThinking], [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 handleSingleRoundRagSelect = useCallback(() => {
const ragModel = const ragModel =
extraModels.find((model) => model.name === context.model_name) ?? extraModels.find((model) => model.name === context.model_name) ??
@@ -749,7 +794,7 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
{!frontendRagActive && ( {!bypassAgentActive && (
<AddAttachmentsButton <AddAttachmentsButton
className={cn( className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
@@ -758,7 +803,7 @@ export function InputBox({
)} )}
/> />
)} )}
{!frontendRagActive && !hideContextTools && <Tooltip {!bypassAgentActive && !hideContextTools && <Tooltip
content={ content={
context.writing_mode context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料" ? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -784,7 +829,7 @@ export function InputBox({
<span className="text-xs font-normal">写作</span> <span className="text-xs font-normal">写作</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && <Tooltip {!bypassAgentActive && <Tooltip
content={ content={
artifactDirectoryModeEnabled artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录" ? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
@@ -813,7 +858,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip {!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={ content={
memoryInjectionEnabled memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆" ? "记忆注入已开启:对话前会注入历史记忆"
@@ -839,9 +884,9 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启" : "参考文献模式已关闭:点击开启"
} }
@@ -869,7 +914,7 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span> <span className="text-xs font-normal">参考文献</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && <Tooltip {!bypassAgentActive && <Tooltip
content={ content={
contextCompressionEnabled contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token" ? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -909,7 +954,9 @@ export function InputBox({
: "flash" : "flash"
} }
tooltipContent={ tooltipContent={
singleRoundRagActive directChatActive
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
: singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料" ? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive : multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料" ? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
@@ -921,7 +968,9 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact && compact &&
(frontendRagActive (directChatActive
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
: frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" ? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking" : context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200" ? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -933,7 +982,9 @@ export function InputBox({
)} )}
> >
<div> <div>
{frontendRagActive ? ( {directChatActive ? (
<GaugeIcon className="size-3" />
) : frontendRagActive ? (
<BookMarkedIcon className="size-3" /> <BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? ( ) : context.mode === "flash" ? (
<ZapIcon className="size-3" /> <ZapIcon className="size-3" />
@@ -948,10 +999,14 @@ export function InputBox({
<div <div
className={cn( className={cn(
"text-xs font-normal", "text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "", context.mode === "ultra" && !directChatActive && !frontendRagActive
? "golden-text"
: "",
)} )}
> >
{frontendRagActive {directChatActive
? t.inputBox.turboMode
: frontendRagActive
? multiRoundRagActive ? multiRoundRagActive
? "多轮 RAG" ? "多轮 RAG"
: "单轮 RAG" : "单轮 RAG"
@@ -969,9 +1024,39 @@ export function InputBox({
{t.inputBox.mode} {t.inputBox.mode}
</DropdownMenuLabel> </DropdownMenuLabel>
<PromptInputActionMenu> <PromptInputActionMenu>
{turboModeVisible && (
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "flash" directChatActive
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleDirectChatSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GaugeIcon
className={cn(
"mr-2 size-4",
directChatActive && "text-sky-700 dark:text-sky-200",
)}
/>
{t.inputBox.turboMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.turboModeDescription}
</div>
</div>
{directChatActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "flash"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -982,7 +1067,7 @@ export function InputBox({
<ZapIcon <ZapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "flash" && !bypassAgentActive && context.mode === "flash" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -992,7 +1077,7 @@ export function InputBox({
{t.inputBox.flashModeDescription} {t.inputBox.flashModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "flash" ? ( {!bypassAgentActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1001,7 +1086,7 @@ export function InputBox({
{supportThinking && ( {supportThinking && (
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "thinking" !bypassAgentActive && context.mode === "thinking"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1012,7 +1097,7 @@ export function InputBox({
<LightbulbIcon <LightbulbIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "thinking" && !bypassAgentActive && context.mode === "thinking" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -1022,7 +1107,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription} {t.inputBox.reasoningModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "thinking" ? ( {!bypassAgentActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1031,7 +1116,7 @@ export function InputBox({
)} )}
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "pro" !bypassAgentActive && context.mode === "pro"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1042,7 +1127,7 @@ export function InputBox({
<GraduationCapIcon <GraduationCapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "pro" && !bypassAgentActive && context.mode === "pro" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -1052,7 +1137,7 @@ export function InputBox({
{t.inputBox.proModeDescription} {t.inputBox.proModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "pro" ? ( {!bypassAgentActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1060,7 +1145,7 @@ export function InputBox({
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "ultra" !bypassAgentActive && context.mode === "ultra"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1071,14 +1156,14 @@ export function InputBox({
<RocketIcon <RocketIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && !bypassAgentActive &&
context.mode === "ultra" && context.mode === "ultra" &&
"text-[#dabb5e]", "text-[#dabb5e]",
)} )}
/> />
<div <div
className={cn( className={cn(
!frontendRagActive && !bypassAgentActive &&
context.mode === "ultra" && context.mode === "ultra" &&
"golden-text", "golden-text",
)} )}
@@ -1090,7 +1175,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription} {t.inputBox.ultraModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "ultra" ? ( {!bypassAgentActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1161,7 +1246,7 @@ export function InputBox({
</DropdownMenuGroup> </DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && ( {!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}> <PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal"> <div className="text-xs font-normal">
@@ -1279,7 +1364,7 @@ export function InputBox({
)} )}
</PromptInputTools> </PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}> <PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && ( {!bypassAgentActive && agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange} onAgentChange={agentSelector.onAgentChange}
@@ -1287,8 +1372,8 @@ export function InputBox({
compact={compact} compact={compact}
/> />
)} )}
{!frontendRagActive && knowledgeSelector} {!bypassAgentActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && ( {!bypassAgentActive && promptPrefix && (
<Tooltip <Tooltip
content={ content={
promptPrefix.on promptPrefix.on
+30 -27
View File
@@ -96,6 +96,9 @@ export const enUS: Translations = {
mode: "Mode", mode: "Mode",
flashMode: "Flash", flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate", flashModeDescription: "Fast and efficient, but may not be accurate",
turboMode: "Turbo",
turboModeDescription:
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
reasoningMode: "Reasoning", reasoningMode: "Reasoning",
reasoningModeDescription: reasoningModeDescription:
"Reasoning before action, balance between time and accuracy", "Reasoning before action, balance between time and accuracy",
@@ -448,17 +451,17 @@ export const enUS: Translations = {
system: "System", system: "System",
light: "Light", light: "Light",
dark: "Dark", dark: "Dark",
lightblue: "Light Blue", lightblue: "Light Blue",
lightgreen: "Green", lightgreen: "Green",
iceblue: "Glacier Blue", iceblue: "Glacier Blue",
lightyellow: "Pale Yellow", lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.", systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.", lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.", darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.", lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.", lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.", iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.", lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language", languageTitle: "Language",
languageDescription: "Switch between languages.", languageDescription: "Switch between languages.",
}, },
@@ -545,13 +548,13 @@ export const enUS: Translations = {
filesSaveAction: "Save", filesSaveAction: "Save",
filesCancelEditAction: "Cancel", filesCancelEditAction: "Cancel",
filesDownloadAction: "Download", filesDownloadAction: "Download",
filesCopyPathAction: "Copy path", filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?", filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved", filesSaveSuccess: "File saved",
filesDeleteAction: "Delete", filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.", filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted", filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.", filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details", viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
@@ -591,26 +594,26 @@ export const enUS: Translations = {
chooseFile: "Choose file", chooseFile: "Choose file",
validating: "Validating archive…", validating: "Validating archive…",
installing: "Installing…", installing: "Installing…",
replace: "Upload again and replace", replace: "Upload again and replace",
install: "Install", install: "Install",
cancel: "Cancel", cancel: "Cancel",
nameLabel: "Name", nameLabel: "Name",
descriptionLabel: "Description", descriptionLabel: "Description",
licenseLabel: "License", licenseLabel: "License",
toolsLabel: "Allowed tools", toolsLabel: "Allowed tools",
warningsLabel: "Warnings", warningsLabel: "Warnings",
conflictOwnedByMe: conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).", "You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget: conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.", "The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther: conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.", "Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom: conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.", "A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictPublic: conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.", "A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint: conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.", "A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
installSuccess: "Skill '{name}' installed.", installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.", installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.", rejectInvalidExt: "Only .zip or .skill files are accepted.",
+23 -21
View File
@@ -91,6 +91,8 @@ export interface Translations {
mode: string; mode: string;
flashMode: string; flashMode: string;
flashModeDescription: string; flashModeDescription: string;
turboMode: string;
turboModeDescription: string;
reasoningMode: string; reasoningMode: string;
reasoningModeDescription: string; reasoningModeDescription: string;
proMode: string; proMode: string;
@@ -383,17 +385,17 @@ export interface Translations {
system: string; system: string;
light: string; light: string;
dark: string; dark: string;
lightblue: string; lightblue: string;
lightgreen: string; lightgreen: string;
iceblue: string; iceblue: string;
lightyellow: string; lightyellow: string;
systemDescription: string; systemDescription: string;
lightDescription: string; lightDescription: string;
darkDescription: string; darkDescription: string;
lightblueDescription: string; lightblueDescription: string;
lightgreenDescription: string; lightgreenDescription: string;
iceblueDescription: string; iceblueDescription: string;
lightyellowDescription: string; lightyellowDescription: string;
languageTitle: string; languageTitle: string;
languageDescription: string; languageDescription: string;
}; };
@@ -475,13 +477,13 @@ export interface Translations {
filesSaveAction: string; filesSaveAction: string;
filesCancelEditAction: string; filesCancelEditAction: string;
filesDownloadAction: string; filesDownloadAction: string;
filesCopyPathAction: string; filesCopyPathAction: string;
filesDiscardConfirm: string; filesDiscardConfirm: string;
filesSaveSuccess: string; filesSaveSuccess: string;
filesDeleteAction: string; filesDeleteAction: string;
filesDeleteConfirm: string; filesDeleteConfirm: string;
filesDeleteSuccess: string; filesDeleteSuccess: string;
filesReadOnlyHint: string; filesReadOnlyHint: string;
viewDetailAction: string; viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
@@ -525,12 +527,12 @@ export interface Translations {
nameLabel: string; nameLabel: string;
descriptionLabel: string; descriptionLabel: string;
licenseLabel: string; licenseLabel: string;
toolsLabel: string; toolsLabel: string;
warningsLabel: string; warningsLabel: string;
conflictOwnedByMe: string; conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string; conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string; conflictOwnedByOther: string;
conflictCustom: string; conflictCustom: string;
conflictPublic: string; conflictPublic: string;
conflictPublicAdminHint: string; conflictPublicAdminHint: string;
installSuccess: string; installSuccess: string;
+26 -24
View File
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
mode: "模式", mode: "模式",
flashMode: "快速", flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准", flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考", reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答", proMode: "专业问答",
@@ -429,17 +431,17 @@ export const zhCN: Translations = {
system: "系统", system: "系统",
light: "浅色", light: "浅色",
dark: "深色", dark: "深色",
lightblue: "浅蓝", lightblue: "浅蓝",
lightgreen: "绿色", lightgreen: "绿色",
iceblue: "冰川蓝", iceblue: "冰川蓝",
lightyellow: "浅黄色", lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。", systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。", lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。", darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。", lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。", lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。", iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。", lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言", languageTitle: "语言",
languageDescription: "在不同语言之间切换。", languageDescription: "在不同语言之间切换。",
}, },
@@ -524,11 +526,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载", filesDownloadAction: "下载",
filesCopyPathAction: "复制路径", filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?", filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存", filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除", filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。", filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除", filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。", filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情", viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
@@ -566,24 +568,24 @@ export const zhCN: Translations = {
chooseFile: "选择文件", chooseFile: "选择文件",
validating: "正在校验文件…", validating: "正在校验文件…",
installing: "安装中…", installing: "安装中…",
replace: "重复上传并覆盖", replace: "重复上传并覆盖",
install: "安装", install: "安装",
cancel: "取消", cancel: "取消",
nameLabel: "名称", nameLabel: "名称",
descriptionLabel: "描述", descriptionLabel: "描述",
licenseLabel: "许可证", licenseLabel: "许可证",
toolsLabel: "允许的工具", toolsLabel: "允许的工具",
warningsLabel: "警告", warningsLabel: "警告",
conflictOwnedByMe: conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。", "您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget: conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。", "同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther: conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。", "技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom: conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。", "已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`, conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`, conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`, installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`, installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`, rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+5 -5
View File
@@ -4,11 +4,11 @@ export interface Model {
model: string; model: string;
display_name: string; display_name: string;
description?: string | null; description?: string | null;
supports_thinking?: boolean; supports_thinking?: boolean;
supports_reasoning_effort?: boolean; supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */ /** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag"; execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
} }
export interface TokenUsageSettings { export interface TokenUsageSettings {
enabled: boolean; enabled: boolean;
+43 -42
View File
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config"; import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log"; import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "../settings"; } from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks"; import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -571,11 +571,11 @@ export function useThreadStream({
onToolStart, onToolStart,
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1507,21 +1507,21 @@ export function useThreadStream({
...context, ...context,
...extraContext, ...extraContext,
}; };
const selectedLlmWikiIds = Array.isArray( const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids, mergedContext.llmwiki_knowledge_base_ids,
) )
? mergedContext.llmwiki_knowledge_base_ids ? mergedContext.llmwiki_knowledge_base_ids
: []; : [];
const selectedAssistantKnowledgeIds = Array.isArray( const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids, mergedContext.assistant_knowledge_base_ids,
) )
? mergedContext.assistant_knowledge_base_ids ? mergedContext.assistant_knowledge_base_ids
: []; : [];
const ragModeEnabled = const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 || selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0; selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) { if (ragModeEnabled) {
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
@@ -1579,6 +1579,7 @@ export function useThreadStream({
context: { context: {
...mergedContext, ...mergedContext,
thinking_enabled: mergedContext.mode !== "flash", thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode: is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra", mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra", subagent_enabled: mergedContext.mode === "ultra",
@@ -1639,18 +1640,18 @@ export function useThreadStream({
sendInFlightRef.current = false; sendInFlightRef.current = false;
} }
}, },
[ [
thread, thread,
_handleOnStart, _handleOnStart,
t.uploads.uploadingFiles, t.uploads.uploadingFiles,
context, context,
referenceModeEntryEnabled, referenceModeEntryEnabled,
localSettings.display.ragModeEnabled, localSettings.display.ragModeEnabled,
queryClient, queryClient,
runOptions, runOptions,
confirmOrRejoin, confirmOrRejoin,
], ],
); );
const handleThreadStop = useCallback(async () => { const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current; const tid = threadIdRef.current;
@@ -1699,7 +1700,7 @@ export function useThreadStream({
? undefined ? undefined
: thread.error; : thread.error;
const mergedThread = { const mergedThread = {
...thread, ...thread,
error: visibleThreadError, error: visibleThreadError,
values: mergedValues, values: mergedValues,
@@ -1709,7 +1710,7 @@ export function useThreadStream({
: mergedMessages, : mergedMessages,
isLoading: thread.isLoading || streamHoldOpen, isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop, stop: handleThreadStop,
} as typeof thread; } as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
+18 -16
View File
@@ -2,18 +2,20 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos"; import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> { export interface AgentThreadState extends Record<string, unknown> {
title: string; title: string;
title_provisional?: boolean; title_provisional?: boolean;
messages: Message[]; messages: Message[];
artifacts: string[]; artifacts: string[];
todos?: Todo[]; todos?: Todo[];
} }
export interface AgentThreadContext extends Record<string, unknown> { export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string; thread_id: string;
model_name: string | undefined; model_name: string | undefined;
thinking_enabled: boolean; 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; is_plan_mode: boolean;
subagent_enabled: boolean; subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
@@ -23,15 +25,15 @@ export interface AgentThreadContext extends Record<string, unknown> {
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */ /** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean; knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */ /** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[]; llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */ /** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[]; assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */ /** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag"; execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string; agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
} }
@@ -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<string> {
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<Message[]>(() =>
readStoredMessages(threadId ?? ""),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(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 };
}
+87 -8
View File
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; 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 { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; 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 // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 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「入库」协助卡 ────────────────────────────────────────────────── // ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -743,6 +762,62 @@ export default function AgentChatPage({
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => { (message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
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); setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next // Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay). // time (covers both direct agent chats and the ChatPage handoff replay).
@@ -767,7 +842,7 @@ export default function AgentChatPage({
...pageReportThemeContext, ...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(老对话要停止)。 // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -852,8 +927,12 @@ export default function AgentChatPage({
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop(); await thread.stop();
}, [thread]); }, [directChat, thread]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -867,7 +946,7 @@ export default function AgentChatPage({
const status = thread.error const status = thread.error
? "error" ? "error"
: thread.isLoading : thread.isLoading || directChat.isLoading
? "streaming" ? "streaming"
: "ready"; : "ready";
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
autoFocus autoFocus
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
pendingKnowledgeSelection.current === null && pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null pendingAssistantKnowledgeSelection.current === null
} }
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
label="知识空间" label="知识空间"
/> />
) )
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
assistantId={agent_id} assistantId={agent_id}
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
readOnly readOnly
label="知识空间" label="知识空间"
/> />
+69 -5
View File
@@ -69,6 +69,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; 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 { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
@@ -348,9 +349,12 @@ export default function ChatPage() {
}); });
const singleRoundRag = useSingleRoundRagMessages(threadId); const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId); const multiRoundRag = useMultiRoundRagMessages(threadId);
const directChat = useDirectChatMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context); const frontendRagSelected = isFrontendRagProfile(settings.context);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -360,13 +364,15 @@ export default function ChatPage() {
if ( if (
singleRoundRag.messages.length === 0 && singleRoundRag.messages.length === 0 &&
ragMessages.length === 0 && ragMessages.length === 0 &&
directChat.messages.length === 0 &&
!singleRoundRag.isLoading && !singleRoundRag.isLoading &&
!multiRoundRag.isLoading !multiRoundRag.isLoading &&
!directChat.isLoading
) { ) {
return base; return base;
} }
const messages = mergeMessagesByTime( const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages], [...base.messages, ...singleRoundRag.messages, ...directChat.messages],
ragMessages, ragMessages,
); );
return { return {
@@ -374,9 +380,14 @@ export default function ChatPage() {
values: { ...base.values, messages }, values: { ...base.values, messages },
messages, messages,
isLoading: isLoading:
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading, base.isLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading,
} as typeof base; } as typeof base;
}, [ }, [
directChat.isLoading,
directChat.messages,
multiRoundRag.isLoading, multiRoundRag.isLoading,
multiRoundRag.messages, multiRoundRag.messages,
researchDisplayThread, researchDisplayThread,
@@ -392,6 +403,52 @@ export default function ChatPage() {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (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 (frontendRagSelected) {
if (message.files && message.files.length > 0) { if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。"); toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
@@ -560,6 +617,8 @@ export default function ChatPage() {
multiRoundRag, multiRoundRag,
multiRoundRagSelected, multiRoundRagSelected,
frontendRagSelected, frontendRagSelected,
directChat,
directChatSelected,
routeBase, routeBase,
setIsNewThread, setIsNewThread,
setThreadId, setThreadId,
@@ -655,6 +714,10 @@ export default function ChatPage() {
saveLastAgentId(agentId); saveLastAgentId(agentId);
}, []); }, []);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
if (singleRoundRag.isLoading) { if (singleRoundRag.isLoading) {
singleRoundRag.stop(); singleRoundRag.stop();
return; return;
@@ -664,7 +727,7 @@ export default function ChatPage() {
return; return;
} }
await thread.stop(); await thread.stop();
}, [multiRoundRag, singleRoundRag, thread]); }, [directChat, multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => { const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined; if (!promptPrefixSettings?.enabled) return undefined;
@@ -914,7 +977,8 @@ export default function ChatPage() {
agentHandoffPending || agentHandoffPending ||
knowledgeSelectionLoading || knowledgeSelectionLoading ||
singleRoundRag.isLoading || singleRoundRag.isLoading ||
multiRoundRag.isLoading multiRoundRag.isLoading ||
directChat.isLoading
} }
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
@@ -42,6 +42,7 @@ from app.gateway.routers import (
compact_menu_overrides, compact_menu_overrides,
dashboard_sessions, dashboard_sessions,
deep_research, deep_research,
direct_chat,
external_llmwiki, external_llmwiki,
feedback, feedback,
fixed_questions, fixed_questions,
@@ -1803,6 +1804,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式 # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router) app.include_router(open_chat.router)
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
app.include_router(direct_chat.router)
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话) # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
app.include_router(open_3qfx.router) app.include_router(open_3qfx.router)
@@ -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",
},
)
+11 -1
View File
@@ -33,6 +33,16 @@ concurrency:
fail_open: true fail_open: true
models: 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 - name: MiniMax-M2.7-fash
display_name: MiniMax-M2.7-fash display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -466,7 +476,7 @@ checkpointer:
# 三层覆盖优先级(高->低): # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight) # 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
@@ -40,77 +40,77 @@ logger = logging.getLogger(__name__)
WRITING_SETUP_MAX_ROUNDS = 8 WRITING_SETUP_MAX_ROUNDS = 8
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">' _ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
_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 = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".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]
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
def _log_es_query_routing_registration( # 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".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, system_prompt: str,
app_config: AppConfig, app_config: AppConfig,
@@ -401,9 +401,9 @@ def _build_middlewares(
if summarization_middleware is not None: if summarization_middleware is not None:
middlewares.append(summarization_middleware) middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled # Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False) is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False) is_writing_setup = cfg.get("is_writing_setup", False)
is_report_structure_setup = cfg.get("is_report_structure_setup", False) is_report_structure_setup = cfg.get("is_report_structure_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode) 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 # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True) 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)) 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 # 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. # actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False)) thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
reasoning_effort = cfg.get("reasoning_effort", None) reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False) is_plan_mode = cfg.get("is_plan_mode", False)
disable_tools = bool(cfg.get("disable_tools", False)) disable_tools = bool(cfg.get("disable_tools", False))
subagent_enabled = cfg.get("subagent_enabled", False) subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、 # 写作配置 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): if not is_writing_mode_report_path(writing_artifact_path):
writing_artifact_path = None writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions( requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
agent_id, agent_id,
cfg.get("excluded_tools") or None, cfg.get("excluded_tools") or None,
) )
try: 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 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 from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills
runtime_available_skills = merge_collector_skill_allowlist( runtime_available_skills = merge_collector_skill_allowlist(
agent_id, runtime_available_skills, runtime_extra_skills(cfg) agent_id, runtime_available_skills, runtime_extra_skills(cfg)
) )
runtime_available_skills = _filter_available_skills_for_excluded_tools( runtime_available_skills = _filter_available_skills_for_excluded_tools(
runtime_available_skills, runtime_available_skills,
requested_excluded_tools, requested_excluded_tools,
) )
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
config["metadata"].update( 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 # Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent( 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), 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( tools=get_available_tools(
model_name=model_name, model_name=model_name,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config, app_config=resolved_app_config,
) )
+ [setup_agent], + [setup_agent],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
@@ -771,19 +773,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Default lead agent (unchanged behavior) # Default lead agent (unchanged behavior)
is_notebook_mode = bool(cfg.get("notebook_search_space_id")) is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or [] notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = ( default_tools = (
[] []
if disable_tools if disable_tools
else get_available_tools( else get_available_tools(
model_name=model_name, model_name=model_name,
groups=agent_config.tool_groups if agent_config else None, groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config, app_config=resolved_app_config,
) )
) )
if agent_id == FORCED_RESEARCH_AGENT_ID: if agent_id == FORCED_RESEARCH_AGENT_ID:
default_tools = filter_forced_research_tools(default_tools) default_tools = filter_forced_research_tools(default_tools)
if is_notebook_mode: if is_notebook_mode:
@@ -825,8 +827,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_mode=writing_mode, writing_mode=writing_mode,
writing_artifact_path=writing_artifact_path, writing_artifact_path=writing_artifact_path,
) )
else: else:
system_prompt = apply_prompt_template( system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents, max_concurrent_subagents=max_concurrent_subagents,
agent_id=agent_id, agent_id=agent_id,
@@ -839,22 +841,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
rag_mode_enabled=rag_mode_enabled, rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode, is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids, notebook_source_ids=notebook_source_ids,
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)), is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
) )
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是 # 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。 # 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower() presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
if presentation_theme in {"dark", "light"}: if presentation_theme in {"dark", "light"}:
system_prompt += ( system_prompt += (
"\n\n【当前界面主题】\n" "\n\n【当前界面主题】\n"
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;" f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
"除非用户明确要求覆盖该主题。" "除非用户明确要求覆盖该主题。"
) )
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id) system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
_log_es_query_routing_registration( _log_es_query_routing_registration(
system_prompt=system_prompt, system_prompt=system_prompt,
app_config=resolved_app_config, app_config=resolved_app_config,
agent_id=agent_id, agent_id=agent_id,
@@ -9,8 +9,8 @@ from functools import lru_cache
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -487,7 +487,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
</subagent_system>""" </subagent_system>"""
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
<ordinary_qa_markdown_format> <ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式: 当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。 - 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
@@ -496,19 +496,19 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。 - 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。 - 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format> </ordinary_qa_markdown_format>
""" """
def _ordinary_qa_markdown_format_enabled() -> bool: def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch.""" """Return the admin-controlled formal Markdown prompt switch."""
try: try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled) return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception: except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True) logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False return False
SYSTEM_PROMPT_TEMPLATE = """ SYSTEM_PROMPT_TEMPLATE = """
<role> <role>
You are {agent_name}, an internal super agent. You are {agent_name}, an internal super agent.
</role> </role>
@@ -883,11 +883,16 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
return "" return ""
try: try:
from deerflow.agents.memory.manager import build_memory_manager 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 from deerflow.runtime.user_context import get_effective_user_id
if not get_memory_config().enabled:
return ""
user_id = get_effective_user_id() user_id = get_effective_user_id()
config = get_effective_memory_config(user_id) config = get_effective_memory_config(user_id)
if not config.enabled or not config.injection.enabled:
return ""
manager = build_memory_manager( manager = build_memory_manager(
user_id=user_id, user_id=user_id,
@@ -1547,13 +1552,13 @@ When a search, knowledge, skill, or MCP tool returns a result list:
if rag_mode_enabled if rag_mode_enabled
else "" else ""
) )
ordinary_qa_format_section = ( ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION _ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if _ordinary_qa_markdown_format_enabled() if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap and not is_bootstrap
and not writing_mode and not writing_mode
and not is_notebook_mode and not is_notebook_mode
and not is_scheduled_run and not is_scheduled_run
else "" else ""
) )
@@ -16,7 +16,7 @@
============ ============
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低): ``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取) 1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True) (仅白名单字段,不能覆盖 ``enabled``)
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``) 2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
3. Pydantic 默认值 3. Pydantic 默认值
@@ -254,13 +254,12 @@ def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。 """返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。 优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
有 per-user 记录时自动强制 enabled=True。 ``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。
""" """
base = _memory_config.model_dump() base = _memory_config.model_dump()
recall_budget = _recall_budget_cache.get(user_id) recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None: if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget base.setdefault("hindsight", {})["recall_budget"] = recall_budget
base["enabled"] = True
try: try:
return MemoryConfig(**base) return MemoryConfig(**base)
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级 except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
@@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
return disable_kwargs 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: 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. """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"): elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter # Native langchain_anthropic: thinking is a direct constructor parameter
model_settings_from_config["thinking"] = {"type": "disabled"} model_settings_from_config["thinking"] = {"type": "disabled"}
requested_reasoning_effort = kwargs.get("reasoning_effort")
if not model_config.supports_reasoning_effort: if not model_config.supports_reasoning_effort:
kwargs.pop("reasoning_effort", None) kwargs.pop("reasoning_effort", None)
model_settings_from_config.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. # Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`); # Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it). # 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 # (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 # nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native
# ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``. # ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``.
# Only models that explicitly opt into thinking should receive vendor-specific # Only models that explicitly opt into thinking should receive vendor-specific
# disable payloads. Generic OpenAI-compatible gateways (including LiteLLM) # disable payloads. Generic OpenAI-compatible gateways (including LiteLLM)
# may reject these otherwise-unknown fields instead of forwarding them. # may reject these otherwise-unknown fields instead of forwarding them.
if ( if (
force_disable_thinking force_disable_thinking
and not thinking_enabled and not thinking_enabled
and model_config.supports_thinking and model_config.supports_thinking
and "extra_body" in getattr(model_class, "model_fields", {}) and "extra_body" in getattr(model_class, "model_fields", {})
): ):
model_settings_from_config["extra_body"] = _deep_merge_dicts( model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"), model_settings_from_config.get("extra_body"),
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}}, {"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
@@ -29,11 +29,11 @@ from typing import Any
from langchain_core.language_models import LanguageModelInput from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
class PatchedChatOpenAI(ChatOpenAI): class PatchedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway. """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): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_tool_call_signatures(payload_msg, orig_msg) _restore_tool_call_signatures(payload_msg, orig_msg)
_restore_reasoning_content(payload_msg, orig_msg) _restore_reasoning_content(payload_msg, orig_msg)
else: else:
# Fallback: match assistant-role entries positionally against AIMessages. # Fallback: match assistant-role entries positionally against AIMessages.
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] 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"] 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): for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg) _restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg) _restore_reasoning_content(payload_msg, ai_msg)
normalize_chat_payload_messages(payload) normalize_chat_payload_messages(payload)
return payload return payload
def _convert_chunk_to_generation_chunk( def _convert_chunk_to_generation_chunk(
self, 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: 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): if not isinstance(payload, dict):
return None return None
reasoning_content = payload.get("reasoning_content") 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: def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
@@ -32,11 +32,11 @@ from langchain_core.messages import (
) )
from langchain_core.messages.tool import tool_call_chunk from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
"""Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``. """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 additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning") reasoning = _dict.get("reasoning")
if reasoning is None:
reasoning = _dict.get("reasoning_content")
if reasoning is not None: if reasoning is not None:
additional_kwargs["reasoning"] = reasoning additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(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): for payload_msg, orig_msg in zip(payload_messages, original_messages):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_field(payload_msg, orig_msg) _restore_reasoning_field(payload_msg, orig_msg)
else: else:
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] 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"] assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg) _restore_reasoning_field(payload_msg, ai_msg)
normalize_chat_payload_messages(payload) normalize_chat_payload_messages(payload)
return payload return payload
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult: def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
"""Preserve vLLM reasoning on non-streaming responses.""" """Preserve vLLM reasoning on non-streaming responses."""
@@ -239,3 +239,66 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup
) )
assert chat_model.extra_body is None 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
+1 -1
View File
@@ -264,7 +264,7 @@ ai_writing:
# 三层覆盖优先级(高->低): # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: v1 version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个 # builtin 始终启用,这里只决定 external 选哪个