Compare commits
3
Commits
master
...
e243ec3a46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e243ec3a46 | ||
|
|
f4c7d41a60 | ||
|
|
d765afe2ac |
No files matched your search
@@ -100,6 +100,8 @@ offline-backend-20260512/backend/tests/*
|
|||||||
# Keep the model configuration regression coverage versioned even though
|
# Keep the model configuration regression coverage versioned even though
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
|
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
|
|||||||
@@ -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)---
|
||||||
|
|||||||
@@ -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: "专业问答",
|
||||||
|
|||||||
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (message.additional_kwargs) {
|
||||||
message.additional_kwargs &&
|
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||||||
"reasoning_content" in message.additional_kwargs
|
if (typeof reasoningContent === "string") {
|
||||||
) {
|
return reasoningContent;
|
||||||
return message.additional_kwargs.reasoning_content as string | null;
|
}
|
||||||
|
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||||||
|
const reasoning = message.additional_kwargs.reasoning;
|
||||||
|
if (typeof reasoning === "string") {
|
||||||
|
return reasoning;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
|
|||||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
// Compatible with the Anthropic gateway
|
// Compatible with the Anthropic gateway
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -1014,9 +1014,11 @@ scheduler runs once per deployment. Per run:
|
|||||||
|
|
||||||
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
||||||
|
|
||||||
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
|
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
|
||||||
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
|
- Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
|
||||||
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
|
- Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
|
||||||
|
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
|
||||||
|
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
|
||||||
|
|
||||||
### IM Channels System (`app/channels/`)
|
### IM Channels System (`app/channels/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -33,6 +33,26 @@ concurrency:
|
|||||||
fail_open: true
|
fail_open: true
|
||||||
|
|
||||||
models:
|
models:
|
||||||
|
- name: qwen3.6
|
||||||
|
display_name: qwen3.6
|
||||||
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
|
model: qwen3.6
|
||||||
|
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||||
|
base_url: http://119.254.86.251:8001/v1
|
||||||
|
request_timeout: 30.0
|
||||||
|
max_retries: 2
|
||||||
|
stream_usage: true
|
||||||
|
supports_thinking: true
|
||||||
|
supports_reasoning_effort: false
|
||||||
|
supports_vision: false
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
when_thinking_disabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: false
|
||||||
- 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
|
||||||
@@ -59,16 +79,24 @@ models:
|
|||||||
supports_vision: false
|
supports_vision: false
|
||||||
- name: qwen3.6
|
- name: qwen3.6
|
||||||
display_name: qwen3.6
|
display_name: qwen3.6
|
||||||
use: langchain_openai:ChatOpenAI
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
model: qwen3.6
|
model: qwen3.6
|
||||||
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||||
base_url: http://119.254.86.251:8001/v1
|
base_url: http://119.254.86.251:8001/v1
|
||||||
request_timeout: 30.0
|
request_timeout: 30.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
stream_usage: true
|
stream_usage: true
|
||||||
supports_thinking: false
|
supports_thinking: true
|
||||||
supports_reasoning_effort: false
|
supports_reasoning_effort: false
|
||||||
supports_vision: false
|
supports_vision: false
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
when_thinking_disabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: false
|
||||||
- name: zai-org/GLM-5-FP8
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: zai-org/GLM-5-FP8
|
display_name: zai-org/GLM-5-FP8
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
@@ -466,7 +494,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)
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ models:
|
|||||||
|
|
||||||
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
|
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
|
||||||
|
|
||||||
|
**vLLM (per-model, do not change other entries)**:
|
||||||
|
|
||||||
|
`use` is per model. SiliconFlow / DeepSeek entries should stay on `langchain_openai:ChatOpenAI`. For a self-hosted vLLM reasoning model (thinking may arrive as `reasoning` on newer vLLM, or `reasoning_content` on older builds), set only that one entry to `VllmChatModel`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
models:
|
||||||
|
- name: qwen3.6
|
||||||
|
display_name: qwen3.6
|
||||||
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
|
model: qwen3.6
|
||||||
|
api_key: $VLLM_API_KEY
|
||||||
|
base_url: http://127.0.0.1:8001/v1
|
||||||
|
supports_thinking: true
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `langchain_openai:ChatOpenAI` path also maps both thinking field names onto the UI's `reasoning_content`. Use `VllmChatModel` when follow-up turns must echo vLLM's original `reasoning` field.
|
||||||
|
|
||||||
**Thinking Models**:
|
**Thinking Models**:
|
||||||
Some models support "thinking" mode for complex reasoning:
|
Some models support "thinking" mode for complex reasoning:
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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
|
||||||
|
|||||||
+6
-1
@@ -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).
|
||||||
|
|||||||
+22
-26
@@ -33,6 +33,7 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResu
|
|||||||
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
|
||||||
|
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, extract_reasoning_text, fill_ui_reasoning_content, restore_reasoning_fields
|
||||||
|
|
||||||
|
|
||||||
class PatchedChatOpenAI(ChatOpenAI):
|
class PatchedChatOpenAI(ChatOpenAI):
|
||||||
@@ -118,12 +119,12 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
|
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
|
||||||
choice = choices[0] if choices else {}
|
choice = choices[0] if choices else {}
|
||||||
delta = choice.get("delta") if isinstance(choice, dict) else None
|
delta = choice.get("delta") if isinstance(choice, dict) else None
|
||||||
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
|
if isinstance(generation_chunk.message, AIMessageChunk):
|
||||||
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
|
generation_chunk.message = apply_reasoning_from_payload(
|
||||||
generation_chunk.message = _append_reasoning_content(
|
|
||||||
generation_chunk.message,
|
generation_chunk.message,
|
||||||
reasoning_content,
|
delta if isinstance(delta, dict) else None,
|
||||||
)
|
)
|
||||||
|
generation_chunk.message = fill_ui_reasoning_content(generation_chunk.message)
|
||||||
return generation_chunk
|
return generation_chunk
|
||||||
|
|
||||||
def _create_chat_result(
|
def _create_chat_result(
|
||||||
@@ -141,9 +142,15 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
message = generation.message
|
message = generation.message
|
||||||
choice = choices[index] if index < len(choices) else {}
|
choice = choices[index] if index < len(choices) else {}
|
||||||
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
||||||
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None)
|
if isinstance(message, AIMessage):
|
||||||
if reasoning_content and isinstance(message, AIMessage):
|
updated = apply_reasoning_from_payload(
|
||||||
message = _append_reasoning_content(message, reasoning_content)
|
message,
|
||||||
|
choice_message if isinstance(choice_message, dict) else None,
|
||||||
|
append=False,
|
||||||
|
)
|
||||||
|
updated = fill_ui_reasoning_content(updated)
|
||||||
|
if updated is not message:
|
||||||
|
message = updated
|
||||||
generation = ChatGeneration(
|
generation = ChatGeneration(
|
||||||
message=message,
|
message=message,
|
||||||
generation_info=generation.generation_info,
|
generation_info=generation.generation_info,
|
||||||
@@ -195,33 +202,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
|
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
|
||||||
"""Re-inject ``reasoning_content`` onto assistant payload message.
|
"""Re-inject thinking fields onto assistant payload message.
|
||||||
|
|
||||||
Some thinking-enabled OpenAI-compatible providers require each historical
|
Some thinking-enabled OpenAI-compatible providers require each historical
|
||||||
assistant turn to include the original ``reasoning_content`` in subsequent
|
assistant turn to include the original thinking field in subsequent
|
||||||
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does
|
requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
|
||||||
not always serialize it back into ``messages``.
|
vLLM use ``reasoning_content``. Echo only the fields this turn originally
|
||||||
|
carried so other models are not given an extra field.
|
||||||
"""
|
"""
|
||||||
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
|
restore_reasoning_fields(payload_msg, orig_msg)
|
||||||
if reasoning_content is not None:
|
|
||||||
payload_msg["reasoning_content"] = reasoning_content
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
if not isinstance(payload, dict):
|
return extract_reasoning_text(payload)
|
||||||
return None
|
|
||||||
reasoning_content = payload.get("reasoning_content")
|
|
||||||
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else 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:
|
||||||
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
|
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
|
||||||
additional_kwargs = dict(message.additional_kwargs)
|
return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
|
||||||
existing = additional_kwargs.get("reasoning_content")
|
|
||||||
if isinstance(existing, str) and existing:
|
|
||||||
if reasoning_content not in existing:
|
|
||||||
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
|
|
||||||
else:
|
|
||||||
additional_kwargs["reasoning_content"] = reasoning_content
|
|
||||||
return message.model_copy(update={"additional_kwargs": additional_kwargs})
|
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
"""Normalize OpenAI-compatible thinking fields across provider versions.
|
||||||
|
|
||||||
|
Older vLLM / DeepSeek put thinking in ``reasoning_content``. Newer vLLM
|
||||||
|
reasoning parsers put it in ``reasoning``. DeerFlow's UI and downstream code
|
||||||
|
canonicalize on ``AIMessage.additional_kwargs.reasoning_content``.
|
||||||
|
|
||||||
|
Only fields that actually appeared on a response are echoed back on later
|
||||||
|
turns, so models that still use ``reasoning_content`` (or send neither) are
|
||||||
|
unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||||
|
|
||||||
|
|
||||||
|
def reasoning_to_text(reasoning: Any) -> str:
|
||||||
|
"""Best-effort extraction of readable reasoning text from provider payloads."""
|
||||||
|
if isinstance(reasoning, str):
|
||||||
|
return reasoning
|
||||||
|
|
||||||
|
if isinstance(reasoning, list):
|
||||||
|
parts = [reasoning_to_text(item) for item in reasoning]
|
||||||
|
return "".join(part for part in parts if part)
|
||||||
|
|
||||||
|
if isinstance(reasoning, dict):
|
||||||
|
for key in ("text", "content", "reasoning"):
|
||||||
|
value = reasoning.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if value is not None:
|
||||||
|
text = reasoning_to_text(value)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
return json.dumps(reasoning, ensure_ascii=False)
|
||||||
|
except TypeError:
|
||||||
|
return str(reasoning)
|
||||||
|
|
||||||
|
if reasoning is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return json.dumps(reasoning, ensure_ascii=False)
|
||||||
|
except TypeError:
|
||||||
|
return str(reasoning)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_reasoning_text(payload: dict | None) -> str | None:
|
||||||
|
"""Read thinking text from ``reasoning_content`` or ``reasoning``.
|
||||||
|
|
||||||
|
Prefer ``reasoning_content`` when both are present so older DeepSeek /
|
||||||
|
vLLM payloads keep their original string. Newer vLLM that only sends
|
||||||
|
``reasoning`` (string or structured) is still captured.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
for key in ("reasoning_content", "reasoning"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = reasoning_to_text(value)
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_streamed_value(existing: Any, incoming: Any) -> Any:
|
||||||
|
if isinstance(existing, str) and isinstance(incoming, str):
|
||||||
|
if incoming and incoming not in existing:
|
||||||
|
return f"{existing}{incoming}"
|
||||||
|
return existing or incoming
|
||||||
|
return incoming if incoming is not None else existing
|
||||||
|
|
||||||
|
|
||||||
|
def apply_reasoning_from_payload(
|
||||||
|
message: AIMessage | AIMessageChunk,
|
||||||
|
payload: dict | None,
|
||||||
|
*,
|
||||||
|
append: bool = True,
|
||||||
|
) -> AIMessage | AIMessageChunk:
|
||||||
|
"""Copy provider thinking fields onto ``additional_kwargs`` for UI + echo-back."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return message
|
||||||
|
|
||||||
|
additional_kwargs = dict(message.additional_kwargs)
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if "reasoning" in payload and payload.get("reasoning") is not None:
|
||||||
|
incoming = payload.get("reasoning")
|
||||||
|
existing = additional_kwargs.get("reasoning")
|
||||||
|
merged = _merge_streamed_value(existing, incoming) if append else incoming
|
||||||
|
if merged is not None and merged != existing:
|
||||||
|
additional_kwargs["reasoning"] = merged
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
text = extract_reasoning_text(payload)
|
||||||
|
if text:
|
||||||
|
existing = additional_kwargs.get("reasoning_content")
|
||||||
|
if append and isinstance(existing, str) and existing:
|
||||||
|
if text not in existing:
|
||||||
|
additional_kwargs["reasoning_content"] = f"{existing}{text}"
|
||||||
|
changed = True
|
||||||
|
elif text != existing:
|
||||||
|
additional_kwargs["reasoning_content"] = text
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return message
|
||||||
|
return message.model_copy(update={"additional_kwargs": additional_kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
def fill_ui_reasoning_content(message: AIMessage | AIMessageChunk) -> AIMessage | AIMessageChunk:
|
||||||
|
"""If LangChain stored ``reasoning`` but not the UI field, copy the text across."""
|
||||||
|
kwargs = message.additional_kwargs
|
||||||
|
reasoning = kwargs.get("reasoning")
|
||||||
|
if reasoning is None or kwargs.get("reasoning_content"):
|
||||||
|
return message
|
||||||
|
return apply_reasoning_from_payload(message, {"reasoning": reasoning}, append=False)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_reasoning_fields(payload_msg: dict, orig_msg: AIMessage) -> None:
|
||||||
|
"""Re-inject the thinking field this assistant turn originally carried.
|
||||||
|
|
||||||
|
Newer vLLM stores thinking on ``reasoning`` and we also copy a UI-facing
|
||||||
|
``reasoning_content`` string from it. Echo the wire field that actually
|
||||||
|
arrived: ``reasoning`` wins when present so we do not send a synthesized
|
||||||
|
``reasoning_content`` to a parser that no longer accepts it. Older
|
||||||
|
DeepSeek / vLLM turns only have ``reasoning_content`` and stay on that
|
||||||
|
field.
|
||||||
|
"""
|
||||||
|
reasoning = orig_msg.additional_kwargs.get("reasoning")
|
||||||
|
if reasoning is not None:
|
||||||
|
payload_msg["reasoning"] = reasoning
|
||||||
|
return
|
||||||
|
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
|
||||||
|
if reasoning_content is not None:
|
||||||
|
payload_msg["reasoning_content"] = reasoning_content
|
||||||
+15
-44
@@ -6,15 +6,16 @@ from assistant messages and streaming deltas. That breaks interleaved
|
|||||||
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
|
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
|
||||||
be echoed back on subsequent turns.
|
be echoed back on subsequent turns.
|
||||||
|
|
||||||
This provider preserves ``reasoning`` on:
|
This provider preserves ``reasoning`` (newer vLLM) and
|
||||||
|
``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
|
||||||
- non-streaming responses
|
- non-streaming responses
|
||||||
- streaming deltas
|
- streaming deltas
|
||||||
- multi-turn request payloads
|
- multi-turn request payloads
|
||||||
|
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ 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
|
||||||
|
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
|
||||||
|
|
||||||
|
|
||||||
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
||||||
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
def _reasoning_to_text(reasoning: Any) -> str:
|
def _reasoning_to_text(reasoning: Any) -> str:
|
||||||
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
|
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
|
||||||
if isinstance(reasoning, str):
|
from deerflow.models.reasoning_fields import reasoning_to_text
|
||||||
return reasoning
|
|
||||||
|
|
||||||
if isinstance(reasoning, list):
|
return reasoning_to_text(reasoning)
|
||||||
parts = [_reasoning_to_text(item) for item in reasoning]
|
|
||||||
return "".join(part for part in parts if part)
|
|
||||||
|
|
||||||
if isinstance(reasoning, dict):
|
|
||||||
for key in ("text", "content", "reasoning"):
|
|
||||||
value = reasoning.get(key)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
if value is not None:
|
|
||||||
text = _reasoning_to_text(value)
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
try:
|
|
||||||
return json.dumps(reasoning, ensure_ascii=False)
|
|
||||||
except TypeError:
|
|
||||||
return str(reasoning)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.dumps(reasoning, ensure_ascii=False)
|
|
||||||
except TypeError:
|
|
||||||
return str(reasoning)
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
|
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
|
||||||
@@ -106,12 +86,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
|
|||||||
function_call["name"] = ""
|
function_call["name"] = ""
|
||||||
additional_kwargs["function_call"] = function_call
|
additional_kwargs["function_call"] = function_call
|
||||||
|
|
||||||
reasoning = _dict.get("reasoning")
|
seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
|
||||||
if reasoning is not None:
|
additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
|
||||||
additional_kwargs["reasoning"] = reasoning
|
|
||||||
reasoning_text = _reasoning_to_text(reasoning)
|
|
||||||
if reasoning_text:
|
|
||||||
additional_kwargs["reasoning_content"] = reasoning_text
|
|
||||||
|
|
||||||
tool_call_chunks = []
|
tool_call_chunks = []
|
||||||
if raw_tool_calls := _dict.get("tool_calls"):
|
if raw_tool_calls := _dict.get("tool_calls"):
|
||||||
@@ -151,11 +127,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
|
|||||||
|
|
||||||
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
|
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
|
||||||
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
|
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
|
||||||
reasoning = orig_msg.additional_kwargs.get("reasoning")
|
restore_reasoning_fields(payload_msg, orig_msg)
|
||||||
if reasoning is None:
|
|
||||||
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
|
|
||||||
if reasoning is not None:
|
|
||||||
payload_msg["reasoning"] = reasoning
|
|
||||||
|
|
||||||
|
|
||||||
class VllmChatModel(ChatOpenAI):
|
class VllmChatModel(ChatOpenAI):
|
||||||
@@ -204,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
|
|||||||
message = generation.message
|
message = generation.message
|
||||||
if not isinstance(message, AIMessage):
|
if not isinstance(message, AIMessage):
|
||||||
continue
|
continue
|
||||||
reasoning = choice.get("message", {}).get("reasoning")
|
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
||||||
if reasoning is None:
|
generation.message = apply_reasoning_from_payload(
|
||||||
continue
|
message,
|
||||||
message.additional_kwargs["reasoning"] = reasoning
|
choice_message if isinstance(choice_message, dict) else None,
|
||||||
reasoning_text = _reasoning_to_text(reasoning)
|
append=False,
|
||||||
if reasoning_text:
|
)
|
||||||
message.additional_kwargs["reasoning_content"] = reasoning_text
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -239,3 +239,67 @@ 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({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
|
||||||
|
assert _extract_reasoning_content({"content": "答案"}) is None
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Reasoning field compatibility: vLLM `reasoning` vs older `reasoning_content`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||||
|
|
||||||
|
from deerflow.models.patched_openai import _extract_reasoning_content, _restore_reasoning_content
|
||||||
|
from deerflow.models.reasoning_fields import (
|
||||||
|
apply_reasoning_from_payload,
|
||||||
|
extract_reasoning_text,
|
||||||
|
fill_ui_reasoning_content,
|
||||||
|
restore_reasoning_fields,
|
||||||
|
)
|
||||||
|
from deerflow.models.vllm_provider import (
|
||||||
|
_convert_delta_to_message_chunk_with_reasoning,
|
||||||
|
_restore_reasoning_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
|
||||||
|
assert extract_reasoning_text({"reasoning": "先分析问题"}) == "先分析问题"
|
||||||
|
assert extract_reasoning_text({"reasoning_content": "逐步推理"}) == "逐步推理"
|
||||||
|
assert extract_reasoning_text({"content": "答案"}) is None
|
||||||
|
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reasoning_reads_structured_reasoning_object() -> None:
|
||||||
|
assert extract_reasoning_text({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
|
||||||
|
assert extract_reasoning_text({"reasoning": {"text": "逐步分析"}}) == "逐步分析"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_prefers_reasoning_content_when_both_present() -> None:
|
||||||
|
assert (
|
||||||
|
extract_reasoning_text({"reasoning_content": "旧字段", "reasoning": "新字段"})
|
||||||
|
== "旧字段"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_new_vllm_reasoning_keeps_ui_field_and_wire_field() -> None:
|
||||||
|
message = apply_reasoning_from_payload(AIMessage(content="答案"), {"reasoning": "新版思考"})
|
||||||
|
assert message.additional_kwargs["reasoning"] == "新版思考"
|
||||||
|
assert message.additional_kwargs["reasoning_content"] == "新版思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_old_reasoning_content_does_not_invent_reasoning() -> None:
|
||||||
|
message = apply_reasoning_from_payload(
|
||||||
|
AIMessage(content="答案"),
|
||||||
|
{"reasoning_content": "旧版思考"},
|
||||||
|
)
|
||||||
|
assert "reasoning" not in message.additional_kwargs
|
||||||
|
assert message.additional_kwargs["reasoning_content"] == "旧版思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_new_vllm_echoes_reasoning_only() -> None:
|
||||||
|
orig = AIMessage(
|
||||||
|
content="答案",
|
||||||
|
additional_kwargs={"reasoning": "新版思考", "reasoning_content": "新版思考"},
|
||||||
|
)
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
restore_reasoning_fields(payload, orig)
|
||||||
|
assert payload["reasoning"] == "新版思考"
|
||||||
|
assert "reasoning_content" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_old_vllm_echoes_reasoning_content_only() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
restore_reasoning_fields(payload, orig)
|
||||||
|
assert payload["reasoning_content"] == "旧版思考"
|
||||||
|
assert "reasoning" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_patched_openai_restore_does_not_add_reasoning_for_deepseek_turns() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "DeepSeek思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
_restore_reasoning_content(payload, orig)
|
||||||
|
assert payload == {"role": "assistant", "content": "答案", "reasoning_content": "DeepSeek思考"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_stream_delta_reasoning_maps_to_ui_field() -> None:
|
||||||
|
chunk = _convert_delta_to_message_chunk_with_reasoning(
|
||||||
|
{"role": "assistant", "content": "", "reasoning": "流式思考"},
|
||||||
|
AIMessageChunk,
|
||||||
|
)
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
assert chunk.additional_kwargs["reasoning"] == "流式思考"
|
||||||
|
assert chunk.additional_kwargs["reasoning_content"] == "流式思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_stream_delta_reasoning_content_does_not_copy_into_reasoning() -> None:
|
||||||
|
chunk = _convert_delta_to_message_chunk_with_reasoning(
|
||||||
|
{"role": "assistant", "content": "", "reasoning_content": "旧流式思考"},
|
||||||
|
AIMessageChunk,
|
||||||
|
)
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
assert "reasoning" not in chunk.additional_kwargs
|
||||||
|
assert chunk.additional_kwargs["reasoning_content"] == "旧流式思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_restore_old_field_stays_on_reasoning_content() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
_restore_reasoning_field(payload, orig)
|
||||||
|
assert payload["reasoning_content"] == "旧版思考"
|
||||||
|
assert "reasoning" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_ui_reasoning_content_from_langchain_kwargs() -> None:
|
||||||
|
message = AIMessage(content="答案", additional_kwargs={"reasoning": "网关已放入reasoning"})
|
||||||
|
filled = fill_ui_reasoning_content(message)
|
||||||
|
assert filled.additional_kwargs["reasoning_content"] == "网关已放入reasoning"
|
||||||
@@ -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 选哪个
|
||||||
|
|||||||
Reference in new issue
Block a user