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
+958 -57

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";
@@ -223,7 +225,11 @@ 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]);
@@ -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,7 +884,7 @@ 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
@@ -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",
@@ -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;
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
mode: "模式", mode: "模式",
flashMode: "快速", flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准", flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考", reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答", proMode: "专业问答",
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Model {
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 {
+1
View File
@@ -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",
+4 -2
View File
@@ -14,6 +14,8 @@ 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";
@@ -29,8 +31,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
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)
@@ -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
@@ -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,
@@ -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).
@@ -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:
@@ -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)
@@ -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 选哪个