Compare commits

..
4 Commits
Author SHA1 Message Date
gozeros 19d3cb96dc sync update from LAN repo (source master-yx @ cf505220) 2026-10-21 15:11:11 +08:00
董宏凯 e243ec3a46 代码提交 2026-09-09 22:46:35 +08:00
董宏凯andCursor f4c7d41a60 fix: accept vLLM thinking in reasoning or reasoning_content
Newer vLLM parsers put CoT in reasoning; older builds and DeepSeek still use reasoning_content. Canonicalize to the UI field and echo only the original wire field so other ChatOpenAI models stay unchanged. Point qwen3.6 at VllmChatModel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:46:08 +08:00
董宏凯andCursor d765afe2ac feat(turbo): add direct LLM chat mode with runtime toggle
Add 极速 mode that streams via /api/direct-chat without the lead agent stack,
plus VITE_TURBO_MODE_VISIBLE in runtime-config.js (default off). Also disable
global memory, tighten Qwen/vLLM thinking control for flash mode, and force
thinking off on the direct-chat endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:58:36 +08:00
63 changed files with 3489 additions and 831 deletions

No files matched your search

+12 -9
View File
@@ -88,18 +88,20 @@ skills/
hermes-agent-main/
.gitignore
offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!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.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
@@ -112,6 +114,7 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
@@ -178,10 +181,10 @@ docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
+1 -1
View File
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
+3
View File
@@ -59,6 +59,9 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+5
View File
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true",
@@ -7,6 +7,7 @@ import {
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GaugeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -93,14 +95,14 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip";
@@ -223,16 +225,20 @@ export function InputBox({
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const turboModeVisible = isTurboModeVisible();
const directChatActive =
turboModeVisible && context.execution_profile === "direct";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const bypassAgentActive = frontendRagActive || directChatActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -253,11 +259,37 @@ export function InputBox({
recommendedQuestions.length > 0;
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
// Frontend-only execution profiles are not model options.
// Do not let the normal model fallback clear them on every render.
if (!turboModeVisible && context.execution_profile === "direct") {
onContextChange?.({
...context,
execution_profile: undefined,
});
return;
}
if (multiRoundRagActive) {
return;
}
if (directChatActive) {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
if (
context.model_name === fallbackModel.name &&
context.execution_profile === "direct"
) {
return;
}
onContextChange?.({
...context,
model_name: fallbackModel.name,
execution_profile: "direct",
});
return;
}
if (modelOptions.length === 0) {
return;
}
@@ -281,7 +313,7 @@ export function InputBox({
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
@@ -314,7 +346,10 @@ export function InputBox({
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile,
execution_profile:
context.execution_profile === "direct"
? "direct"
: model.execution_profile,
});
setModelDialogOpen(false);
},
@@ -346,6 +381,16 @@ export function InputBox({
[onContextChange, context, models, supportThinking],
);
const handleDirectChatSelect = useCallback(() => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
execution_profile: "direct",
});
}, [context, models, onContextChange]);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
@@ -749,7 +794,7 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{!frontendRagActive && (
{!bypassAgentActive && (
<AddAttachmentsButton
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",
@@ -758,7 +803,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!bypassAgentActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -784,7 +829,7 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
{!bypassAgentActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
@@ -813,7 +858,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -839,9 +884,9 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启"
}
@@ -869,7 +914,7 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
{!bypassAgentActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -909,7 +954,9 @@ export function InputBox({
: "flash"
}
tooltipContent={
singleRoundRagActive
directChatActive
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
: singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive
? "多轮 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",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
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"
: 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"
@@ -933,7 +982,9 @@ export function InputBox({
)}
>
<div>
{frontendRagActive ? (
{directChatActive ? (
<GaugeIcon className="size-3" />
) : frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
@@ -948,10 +999,14 @@ export function InputBox({
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
context.mode === "ultra" && !directChatActive && !frontendRagActive
? "golden-text"
: "",
)}
>
{frontendRagActive
{directChatActive
? t.inputBox.turboMode
: frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
@@ -969,9 +1024,39 @@ export function InputBox({
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
{turboModeVisible && (
<PromptInputActionMenuItem
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-muted-foreground/65",
)}
@@ -982,7 +1067,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "flash" &&
!bypassAgentActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -992,7 +1077,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "flash" ? (
{!bypassAgentActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1001,7 +1086,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "thinking"
!bypassAgentActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1012,7 +1097,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "thinking" &&
!bypassAgentActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -1022,7 +1107,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "thinking" ? (
{!bypassAgentActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1031,7 +1116,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "pro"
!bypassAgentActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1042,7 +1127,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "pro" &&
!bypassAgentActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -1052,7 +1137,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "pro" ? (
{!bypassAgentActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1060,7 +1145,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "ultra"
!bypassAgentActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1071,14 +1156,14 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!frontendRagActive &&
!bypassAgentActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!frontendRagActive &&
!bypassAgentActive &&
context.mode === "ultra" &&
"golden-text",
)}
@@ -1090,7 +1175,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "ultra" ? (
{!bypassAgentActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1161,7 +1246,7 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
<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]")}>
<div className="text-xs font-normal">
@@ -1279,7 +1364,7 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
{!bypassAgentActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1287,8 +1372,8 @@ export function InputBox({
compact={compact}
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && (
{!bypassAgentActive && knowledgeSelector}
{!bypassAgentActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -69,7 +69,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
: []),
{
id: "account",
@@ -24,7 +24,10 @@ import {
applyCompactMenuOverrides,
type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides";
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
import {
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -149,7 +152,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
match: (pathname) =>
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
];
+30 -27
View File
@@ -96,6 +96,9 @@ export const enUS: Translations = {
mode: "Mode",
flashMode: "Flash",
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",
reasoningModeDescription:
"Reasoning before action, balance between time and accuracy",
@@ -448,17 +451,17 @@ export const enUS: Translations = {
system: "System",
light: "Light",
dark: "Dark",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
lightblue: "Light Blue",
lightgreen: "Green",
iceblue: "Glacier Blue",
lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language",
languageDescription: "Switch between languages.",
},
@@ -545,13 +548,13 @@ export const enUS: Translations = {
filesSaveAction: "Save",
filesCancelEditAction: "Cancel",
filesDownloadAction: "Download",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved",
filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details",
stateSheet: {
archivedTitle: "Archived skills",
@@ -591,26 +594,26 @@ export const enUS: Translations = {
chooseFile: "Choose file",
validating: "Validating archive…",
installing: "Installing…",
replace: "Upload again and replace",
replace: "Upload again and replace",
install: "Install",
cancel: "Cancel",
nameLabel: "Name",
descriptionLabel: "Description",
licenseLabel: "License",
toolsLabel: "Allowed tools",
warningsLabel: "Warnings",
conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
warningsLabel: "Warnings",
conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.",
+23 -21
View File
@@ -91,6 +91,8 @@ export interface Translations {
mode: string;
flashMode: string;
flashModeDescription: string;
turboMode: string;
turboModeDescription: string;
reasoningMode: string;
reasoningModeDescription: string;
proMode: string;
@@ -383,17 +385,17 @@ export interface Translations {
system: string;
light: string;
dark: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
lightblue: string;
lightgreen: string;
iceblue: string;
lightyellow: string;
systemDescription: string;
lightDescription: string;
darkDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
lightblueDescription: string;
lightgreenDescription: string;
iceblueDescription: string;
lightyellowDescription: string;
languageTitle: string;
languageDescription: string;
};
@@ -475,13 +477,13 @@ export interface Translations {
filesSaveAction: string;
filesCancelEditAction: string;
filesDownloadAction: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
filesCopyPathAction: string;
filesDiscardConfirm: string;
filesSaveSuccess: string;
filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string;
stateSheet: {
archivedTitle: string;
@@ -525,12 +527,12 @@ export interface Translations {
nameLabel: string;
descriptionLabel: string;
licenseLabel: string;
toolsLabel: string;
warningsLabel: string;
conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
toolsLabel: string;
warningsLabel: string;
conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string;
conflictCustom: string;
conflictPublic: string;
conflictPublicAdminHint: string;
installSuccess: string;
+26 -24
View File
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
mode: "模式",
flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答",
@@ -429,17 +431,17 @@ export const zhCN: Translations = {
system: "系统",
light: "浅色",
dark: "深色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
lightblue: "浅蓝",
lightgreen: "绿色",
iceblue: "冰川蓝",
lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言",
languageDescription: "在不同语言之间切换。",
},
@@ -524,11 +526,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情",
stateSheet: {
archivedTitle: "归档的技能",
@@ -566,24 +568,24 @@ export const zhCN: Translations = {
chooseFile: "选择文件",
validating: "正在校验文件…",
installing: "安装中…",
replace: "重复上传并覆盖",
replace: "重复上传并覆盖",
install: "安装",
cancel: "取消",
nameLabel: "名称",
descriptionLabel: "描述",
licenseLabel: "许可证",
toolsLabel: "允许的工具",
warningsLabel: "警告",
conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
warningsLabel: "警告",
conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+13 -5
View File
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
if (message.additional_kwargs) {
const reasoningContent = message.additional_kwargs.reasoning_content;
if (typeof reasoningContent === "string") {
return reasoningContent;
}
// 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)) {
const part = message.content[0];
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// Compatible with the Anthropic gateway
+5 -5
View File
@@ -4,11 +4,11 @@ export interface Model {
model: string;
display_name: string;
description?: string | null;
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag";
}
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
}
export interface TokenUsageSettings {
enabled: boolean;
@@ -49,12 +49,23 @@ export type MenuItem = {
path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean;
children?: MenuItem[];
};
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [
{
id: "qa-center",
@@ -266,12 +277,7 @@ export const sidebarMenuItems: MenuItem[] = [
},
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
MARKDOWN_DOCX_MENU_ITEM,
{
id: "mgmt-tasks",
label: "定时任务",
@@ -338,6 +344,7 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理",
icon: BookOpen,
path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
},
{
id: "resource-knowledge-base",
@@ -373,6 +380,9 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
}
+43 -42
View File
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -571,11 +571,11 @@ export function useThreadStream({
onToolStart,
onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n();
const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const { t } = useI18n();
const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
// Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1507,21 +1507,21 @@ export function useThreadStream({
...context,
...extraContext,
};
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids,
)
? mergedContext.assistant_knowledge_base_ids
: [];
const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
@@ -1579,6 +1579,7 @@ export function useThreadStream({
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
@@ -1639,18 +1640,18 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[
thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
confirmOrRejoin,
],
);
[
thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
confirmOrRejoin,
],
);
const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current;
@@ -1699,7 +1700,7 @@ export function useThreadStream({
? undefined
: thread.error;
const mergedThread = {
const mergedThread = {
...thread,
error: visibleThreadError,
values: mergedValues,
@@ -1709,7 +1710,7 @@ export function useThreadStream({
: mergedMessages,
isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop,
} as typeof thread;
} as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
+18 -16
View File
@@ -2,18 +2,20 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadState extends Record<string, unknown> {
title: string;
title_provisional?: boolean;
messages: Message[];
artifacts: string[];
todos?: Todo[];
}
export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;
model_name: string | undefined;
thinking_enabled: boolean;
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
thinking_force_disabled?: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
@@ -23,15 +25,15 @@ export interface AgentThreadContext extends Record<string, unknown> {
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -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 };
}
@@ -116,6 +116,14 @@ export interface EightBfTaskProcessReportReceipt {
id: string;
}
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -180,7 +188,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
});
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null));
@@ -198,7 +206,7 @@ export async function updateEightBfTaskProcessReport(
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
body: processReportBody(input),
},
);
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -88,14 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
const artifactPositionByAgent = useMemo(() => {
const map = new Map<string, number>();
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
map.set(seat.agent_id, seat.artifact_position);
}
}
return map;
}, [configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId],
[taskId, artifactPositionByAgent],
);
useEffect(() => {
+87 -8
View File
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -560,9 +562,26 @@ export default function AgentChatPage({
},
});
const directChat = useDirectChatMessages(threadId);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread } = useResearchWritingFeed(threadId, thread);
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (directChat.messages.length === 0 && !directChat.isLoading) {
return base;
}
const messages = [...base.messages, ...directChat.messages];
return {
...base,
values: { ...base.values, messages },
messages,
isLoading: base.isLoading || directChat.isLoading,
} as typeof base;
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -743,6 +762,62 @@ export default function AgentChatPage({
const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<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);
// Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay).
@@ -767,7 +842,7 @@ export default function AgentChatPage({
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -852,8 +927,12 @@ export default function AgentChatPage({
);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop();
}, [thread]);
}, [directChat, thread]);
const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -867,7 +946,7 @@ export default function AgentChatPage({
const status = thread.error
? "error"
: thread.isLoading
: thread.isLoading || directChat.isLoading
? "streaming"
: "ready";
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
label="知识空间"
/>
)
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
readOnly
label="知识空间"
/>
+69 -5
View File
@@ -73,6 +73,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -364,9 +365,12 @@ export default function ChatPage() {
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const directChat = useDirectChatMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -376,13 +380,15 @@ export default function ChatPage() {
if (
singleRoundRag.messages.length === 0 &&
ragMessages.length === 0 &&
directChat.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
!multiRoundRag.isLoading &&
!directChat.isLoading
) {
return base;
}
const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages],
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
ragMessages,
);
return {
@@ -390,9 +396,14 @@ export default function ChatPage() {
values: { ...base.values, messages },
messages,
isLoading:
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
base.isLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading,
} as typeof base;
}, [
directChat.isLoading,
directChat.messages,
multiRoundRag.isLoading,
multiRoundRag.messages,
researchDisplayThread,
@@ -408,6 +419,52 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
@@ -574,6 +631,8 @@ export default function ChatPage() {
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
directChat,
directChatSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -669,6 +728,10 @@ export default function ChatPage() {
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
@@ -678,7 +741,7 @@ export default function ChatPage() {
return;
}
await thread.stop();
}, [multiRoundRag, singleRoundRag, thread]);
}, [directChat, multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -928,7 +991,8 @@ export default function ChatPage() {
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading
multiRoundRag.isLoading ||
directChat.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
@@ -55,12 +55,14 @@ function payloadFrom(
): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId;
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
const position = config.positionFor?.(target) ?? config.position;
return {
taskId: config.taskId,
agentId,
agentName: target.agentName?.trim() || agentId,
content,
position: config.position,
position,
reportName: name,
submitStatus,
};
@@ -55,6 +55,13 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/
position_id?: string | null;
/**
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
* 注意与 `position_id`(岗位协同的岗位归属)无关。
*/
artifact_position?: number | null;
/**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
@@ -13,7 +13,14 @@ export interface TaskProcessReportPayload {
export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */
taskId: string;
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
position: number;
/**
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
* null / undefined = 该席位未配置,回退 `position` 默认值。
*/
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
}
@@ -697,6 +697,8 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null,
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
artifact_position: s.artifact_position ?? null,
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null,
};
@@ -73,6 +73,21 @@ export interface LlmWikiKnowledgeBase {
is_conversation_deposit?: boolean;
}
export interface LlmWikiEmbeddingModel {
id: string;
name: string;
provider?: string | null;
model_name?: string | null;
status?: string | null;
is_default: boolean;
available: boolean;
}
export interface LlmWikiEmbeddingModelsResponse {
models: LlmWikiEmbeddingModel[];
default_model_id?: string | null;
}
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
wiki: {
status: "ready" | "empty" | "unavailable";
@@ -268,6 +283,9 @@ export interface LlmWikiWikiIndexStatus {
export interface LocalWikiEncoderStatus {
provider: string;
configured_provider?: string | null;
mode?: "configured" | "local";
using_local?: boolean;
model: string;
dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed";
@@ -424,6 +442,17 @@ export async function getLlmWikiKnowledgeBase(
);
}
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
await apiFetch(`${API()}/embedding-models`),
"读取知识服务向量模型",
);
return {
models: payload.models ?? [],
default_model_id: payload.default_model_id ?? null,
};
}
export async function createLlmWikiWeKnoraEmbedSession(
id: string,
tab: "documents" | "wiki" | "graph",
@@ -457,6 +486,7 @@ export async function createLlmWikiKnowledgeBase(input: {
description: string;
type: "document" | "faq";
wiki_enabled?: boolean;
embedding_model_id?: string;
}): Promise<LlmWikiKnowledgeBase> {
return readJson(
await apiFetch(`${API()}/knowledge-bases`, {
@@ -654,6 +684,13 @@ export async function configureLocalWikiEncoder(
);
}
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
"恢复配置文件编码器",
);
}
export async function uploadLocalWikiEncoder(
files: File[],
onProgress?: (percent: number) => void,
@@ -688,10 +725,15 @@ export async function uploadLocalWikiEncoder(
payload = null;
}
if (request.status < 200 || request.status >= 300) {
const detail = payload && typeof payload === "object" && "detail" in payload
const rawDetail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`;
reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail
? String((rawDetail as { message: unknown }).message)
: typeof rawDetail === "string"
? rawDetail
: JSON.stringify(rawDetail);
reject(new Error(userFacingKnowledgeMessage(detail)));
return;
}
onProgress?.(100);
@@ -1268,6 +1310,17 @@ export function useLlmWikiSettings(enabled = true) {
});
}
export function useLlmWikiEmbeddingModels(enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
queryFn: listLlmWikiEmbeddingModels,
enabled,
staleTime: 60_000,
refetchOnMount: "always",
});
}
export function useLlmWikiActions() {
const queryClient = useQueryClient();
const invalidateKnowledge = () =>
@@ -34,6 +34,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth";
@@ -48,7 +55,9 @@ import {
searchLlmWiki,
startLocalWikiEncoder,
uploadLocalWikiEncoder,
restoreConfiguredWikiEncoder,
useLlmWikiActions,
useLlmWikiEmbeddingModels,
useLlmWikiKnowledgeBases,
useLlmWikiSettings,
useLlmWikiSettingsActions,
@@ -68,6 +77,14 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试";
}
function encoderProviderLabel(provider?: string | null): string {
const normalized = provider?.trim().toLowerCase();
if (normalized === "local") return "本地 BGE-M3";
if (normalized === "openai") return "OpenAI";
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
return provider?.trim() || "未配置";
}
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索";
@@ -457,18 +474,50 @@ function CreateKnowledgeDialog({
open: boolean;
pending: boolean;
onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true);
const [embeddingModelId, setEmbeddingModelId] = useState("");
const embeddingModels = useLlmWikiEmbeddingModels(open);
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
useEffect(() => {
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
if (!open) {
setName("");
setDescription("");
setWikiEnabled(true);
setEmbeddingModelId("");
}
}, [open]);
useEffect(() => {
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const provider = model.provider?.trim();
const implementation = model.model_name?.trim();
const suffix = [provider, implementation].filter(Boolean).join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
@@ -476,8 +525,33 @@ function CreateKnowledgeDialog({
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
</div></div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
)}
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
</div>
</div>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
</DialogContent>
</Dialog>
);
@@ -501,11 +575,12 @@ export function LocalEncoderSettingsCard() {
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status);
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
setModelPath(status.model_path || "");
};
const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); },
onError: (error) => toast.error(messageOf(error)),
});
const upload = useMutation({
@@ -514,7 +589,7 @@ export function LocalEncoderSettingsCard() {
onSuccess: (status) => {
applyStatus(status);
setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`);
},
onError: (error) => toast.error(messageOf(error)),
});
@@ -526,7 +601,17 @@ export function LocalEncoderSettingsCard() {
},
onError: (error) => toast.error(messageOf(error)),
});
const restoreConfigured = useMutation({
mutationFn: restoreConfiguredWikiEncoder,
onSuccess: (nextStatus) => {
applyStatus(nextStatus);
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data;
const usingLocal = status?.using_local ?? status?.provider === "local";
const localOverrideActive = status?.mode === "local";
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB`
@@ -535,29 +620,49 @@ export function LocalEncoderSettingsCard() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle>
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}>
{encoder.isError
? "状态读取失败"
: encoder.isLoading
? "读取中"
: !usingLocal
? "配置编码器生效中"
: status?.state === "ready"
? "本地编码器已就绪"
: status?.state === "starting"
? "本地编码器启动中"
: status?.state === "failed"
? "本地编码器启动失败"
: "本地编码器未启动"}
</Badge>
<span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>}
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>}
<span className="text-muted-foreground">
{status
? usingLocal
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
: "正在读取状态"}
</span>
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button>
</div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
@@ -576,7 +681,7 @@ export function LocalEncoderSettingsCard() {
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"}
</p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
@@ -586,10 +691,16 @@ export function LocalEncoderSettingsCard() {
</div>
<div className="flex flex-wrap gap-2">
<Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button>
{localOverrideActive && (
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
恢复 config.yaml 编码器
</Button>
)}
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button>
@@ -601,13 +601,19 @@ export function WeKnoraWikiManagementPage() {
}
};
const vectorStatusText = !indexStatus.data?.local_index_enabled
? "本地向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: "整库尚未完成向量化,检索使用 Wiki 接口";
const vectorStatusText = indexStatus.isLoading
? "正在读取 Wiki 镜像向量状态"
: indexStatus.isError
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}`
: !indexStatus.data?.local_index_enabled
? "Wiki 镜像向量未启用,检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: indexStatus.data.last_error
? `整库向量化失败:${indexStatus.data.last_error}`
: "整库尚未完成向量化,检索使用 Wiki 接口";
return (
<div className="flex h-full min-h-0 flex-col bg-background">
@@ -626,7 +632,7 @@ export function WeKnoraWikiManagementPage() {
variant="outline"
size="sm"
onClick={() => void vectorize()}
disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
>
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
<Loader2 className="mr-2 size-4 animate-spin" />
@@ -652,6 +658,17 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
<aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3">
{indexStatus.isLoading && (
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
</div>
)}
{indexStatus.isError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
</div>
)}
{indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs">
+39 -5
View File
@@ -1028,9 +1028,11 @@ scheduler runs once per deployment. Per run:
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
- 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
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
- 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
- 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/`)
@@ -1140,6 +1142,15 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
### Conditional WeKnora LLMWiki
**Embedding ownership:** WeKnora's per-knowledge-base `embedding_model_id`
indexes uploaded source documents; `llmwiki.local_wiki_index.embedding` indexes
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
choices. `GET /api/llmwiki/embedding-models` returns a strict credential-free
projection of WeKnora Embedding models, and KB creation must validate and pass
the selected active model id. When the caller omits a choice, retain WeKnora's
active-default behavior. Existing KBs cannot change this binding in place, and
cross-KB WeKnora searches should use model-compatible knowledge bases.
Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`.
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
@@ -1336,6 +1347,14 @@ Automatic conversation summarization when approaching token limits:
when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`.
- LangChain keeps the agent prompt in `ModelRequest.system_message`, separate
from `ModelRequest.messages`. Compression must merge its hidden summary into
that dedicated slot and remove every `SystemMessage` from the ordinary
message list. Strict Qwen-style adapters accept only one system message at
index zero and otherwise return HTTP 400 (`system message must be at the
beginning`). Keep this normalization adapter-independent; the payload-level
OpenAI-compatible normalizer is a second line of defence and also merges
adjacent leading system messages.
- Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1815,8 +1834,8 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
pages need backfill, not a forced rebuild of every page.
FastEmbed is a standard backend dependency. Encoder configuration, directory
upload and warmup live only in the administrator global settings dialog.
A runtime `system_settings.json` path override wins over config.yaml; uploaded
files are streamed into `.deer-flow/models/bge-m3`, never the source tree.
When the effective provider is local, its `system_settings.json` path override wins over config.yaml; uploaded
files are streamed into immutable `.deer-flow/models/bge-m3-revisions/<revision>` directories, never the source tree.
Encoder warmup is explicitly started from the settings UI/API and must never block application startup
or turn a model-load failure into a DeerFlow service failure. Local provider
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
@@ -1824,6 +1843,21 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
`local_process_isolation=true` by default: inference runs in the bundled JSONL
worker subprocess, and shutdown must terminate that worker without waiting on
model teardown.
- The administrator encoder override is an explicit persisted mode, not an
inference from whether `model_path` happens to be non-empty. `configured`
uses the current `config.yaml` provider (including OpenAI-compatible);
saving/uploading a local directory sets `local` plus a new model revision,
while `/api/llmwiki/local-encoder/use-configured` switches back. Every switch
hot-rebuilds embedding/cache/search/sync/retrieval as one graph and updates
the harness runtime registration. Status must remain readable for a remote
provider. The scheduler stays alive as a lightweight config watcher but only
scans when effective `enabled && auto_sync`; keep the default/portable release
configs at `auto_sync=true` because asynchronous Wiki generation can finish
after the event-triggered first scan. A changed local model revision must
change the embedding fingerprint so vectors from replaced weights are never
reused. Uploaded weights live in immutable revision directories. A replaced
embedding generation gets a bounded grace period for in-flight work, then
rejects stragglers, drains real thread work, and releases its model process.
- Every successful mapping snapshot is mirrored to the global assistant base,
including empty snapshots so deleted source pages are retired. Re-import is
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
+20 -3
View File
@@ -79,7 +79,7 @@ Middlewares execute in strict order, each handling a specific concern:
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
| 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
| 4 | **SummarizationMiddleware** | Reduces context near token limits and folds the hidden summary into one leading system message for strict model adapters (optional) |
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
@@ -928,6 +928,13 @@ the file through the existing thread-artifact endpoint.
远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。
新建知识库时会通过 `GET /api/llmwiki/embedding-models` 读取 WeKnora 中可用的
Embedding 模型,并把所选 `embedding_model_id` 明确传给创建接口。该模型只负责
WeKnora 中上传原文的分块向量化;它和下文 DeerFlow 自己的 Wiki 镜像编码器是两套
独立配置,因此选择 OpenAI/OpenAI-compatible 原文编码器时不要求同时启用本地
BGE-M3。模型列表响应只暴露名称、provider、状态等白名单字段,不返回密钥或连接
参数。模型绑定只对新建库生效;需要跨知识库检索时,各库应使用相同或兼容的向量模型。
## DeerFlow-local LLMWiki Wiki vector index
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
@@ -1176,13 +1183,23 @@ llmwiki:
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
「设置和更多 → 设置 → 本地编码器」中选择服务器目录,或从浏览器上传完整模型目录;
「设置和更多 → 设置 → Wiki 镜像编码器」中选择服务器目录,或从浏览器上传完整模型目录;
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
`.deer-flow/models/bge-m3`,均不进入源码仓库。系统强制 `local_files_only`,
`.deer-flow/models/bge-m3-revisions/<revision>` 的不可变版本目录,均不进入源码仓库。系统强制 `local_files_only`,
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
运行时编码器模式持久化为 `configured` 或 `local`。默认 `configured` 严格使用
`config.yaml` 中的 OpenAI、OpenAI-compatible 或 local provider;保存服务器目录或
上传模型会显式切到 `local` 并热重建 embedding/search/sync/retrieval 服务,不需要
重启 Gateway。`POST /api/llmwiki/local-encoder/use-configured` 可立即恢复配置文件中的
provider。`GET /api/llmwiki/local-encoder/status` 在远程 provider 下也正常返回当前
provider/mode,不再把“不是 local”当成状态接口错误。每次激活本地模型都会生成新的
模型修订指纹,避免同一路径替换权重后误复用旧向量。切换后的旧编码器会给在途任务
保留短暂宽限期,随后等待当前编码调用结束并自动回收模型进程。
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
@@ -42,6 +42,7 @@ from app.gateway.routers import (
compact_menu_overrides,
dashboard_sessions,
deep_research,
direct_chat,
external_llmwiki,
feedback,
fixed_questions,
@@ -1020,20 +1021,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# Local Wiki mirror/index scans are opt-in and run outside request
# handling. Per-library database leases keep multi-worker startup safe.
app.state.llmwiki_index_scheduler_task = None
local_wiki_index = app.state.config.llmwiki.local_wiki_index
if (
local_wiki_index.enabled
and local_wiki_index.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
):
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
# The dormant scheduler also watches config.yaml/system-settings
# changes. Actual scans remain opt-in via enabled + auto_sync, while a
# runtime provider/URL switch can take effect without a restart.
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
local_wiki_index_scheduler_loop(app)
)
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
local_wiki_index_scheduler_loop(app)
)
# ?? recall_budget cache?? DB ????,? get_effective_memory_config ?????
try:
@@ -1829,6 +1824,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router)
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
app.include_router(direct_chat.router)
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
app.include_router(open_3qfx.router)
@@ -391,7 +391,6 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# DeerFlow-local Wiki mirror/vector index. The store is always wired so
# migrations and status inspection work while feature flags are off;
# network/search services only exist when explicitly enabled.
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
local_index_config = config.llmwiki.local_wiki_index
@@ -404,78 +403,12 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.llmwiki_vector_search = None
app.state.llmwiki_sync_service = None
app.state.llmwiki_retrieval_service = None
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
resolved_llmwiki = get_resolved_llmwiki_runtime(config)
weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
embedding_config = local_index_config.embedding
# The administrator may select or upload the offline encoder from the
# UI. This runtime-owned value deliberately overrides config.yaml and
# survives application restarts without modifying release assets.
if embedding_config.provider == "local":
from deerflow.config.system_settings import load_system_settings
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
if runtime_model_path:
embedding_config = embedding_config.model_copy(
update={"local_model_path": runtime_model_path}
)
embedding_configured = bool(
embedding_config.enabled
and (embedding_config.base_url or embedding_config.provider == "local")
and embedding_config.model.strip()
)
if embedding_configured:
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(embedding_config)
if local_index_config.enabled and app.state.llmwiki_embedding is not None:
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
app.state.llmwiki_vector_cache = WikiVectorCache(
app.state.llmwiki_index_store,
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
revision_check_seconds=local_index_config.cache_revision_check_seconds,
)
app.state.llmwiki_vector_search = WikiVectorSearchService(
local_index_config,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
)
if weknora_client is not None:
app.state.llmwiki_sync_service = WikiSyncService(
local_index_config,
app.state.llmwiki_index_store,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
weknora_client,
)
from app.gateway.knowledge_transfer import sync_to_global
async def on_wiki_vectorized(mapping):
return await sync_to_global(app, mapping)
app.state.llmwiki_sync_service.on_completed = on_wiki_vectorized
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
app.state.llmwiki_retrieval_service = WikiRetrievalService(
app.state.llmwiki_index_store,
vector_search=(
app.state.llmwiki_vector_search
if local_index_config.internal_search_enabled
else None
),
client=weknora_client,
)
register_local_wiki_index_runtime(
store=app.state.llmwiki_index_store,
search=app.state.llmwiki_vector_search,
sync=app.state.llmwiki_sync_service,
retrieval=app.state.llmwiki_retrieval_service,
)
# Build every embedding-dependent object through the same hot-swap
# path used by the administrator settings API. This keeps startup,
# uploaded-local selection and config.yaml reloads behaviorally equal.
await rebuild_local_wiki_index_runtime(app, force=True)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
@@ -714,9 +647,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
if llmwiki_embedding is not None:
await llmwiki_embedding.close()
from app.gateway.llmwiki_index_runtime import close_local_wiki_index_runtime
await close_local_wiki_index_runtime(app)
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
@@ -0,0 +1,369 @@
"""Hot-swappable runtime composition for the DeerFlow-local Wiki index.
The file configuration remains the source of truth for the normal embedding
provider. Administrators can temporarily select the uploaded offline BGE-M3
encoder through ``system_settings.json`` without rewriting release assets or
restarting Gateway.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
from pathlib import Path
from typing import Any
from fastapi import FastAPI
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.config.system_settings import LocalWikiEncoderSettings, load_system_settings
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
logger = logging.getLogger(__name__)
_EMBEDDING_RETIREMENT_GRACE_SECONDS = 600.0
def resolve_effective_local_wiki_index_config(
config: AppConfig,
encoder_settings: LocalWikiEncoderSettings,
) -> LocalWikiIndexConfig:
"""Apply the persisted encoder mode to a copy of the file configuration."""
local_index = config.llmwiki.local_wiki_index
configured_embedding = local_index.embedding
runtime_model_path = encoder_settings.model_path.strip()
if encoder_settings.mode == "local":
# Directory upload is intentionally a BGE-M3 contract. A configured
# OpenAI model/dimension must not leak into the local FastEmbed worker.
embedding = configured_embedding.model_copy(
update={
"enabled": True,
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"base_url": "",
"api_key": "",
"local_model_path": runtime_model_path or configured_embedding.local_model_path,
"local_model_revision": encoder_settings.model_revision,
}
)
elif configured_embedding.provider == "local":
# Keep the historical invariant: an administrator-selected directory
# overrides config.yaml when the configured provider itself is local.
updates = {"local_model_revision": encoder_settings.model_revision}
if runtime_model_path:
updates["local_model_path"] = runtime_model_path
embedding = configured_embedding.model_copy(
update=updates
)
else:
embedding = configured_embedding
return local_index.model_copy(update={"embedding": embedding})
def _resolved_local_model_path(config: LocalWikiIndexConfig) -> Path | None:
raw = config.embedding.local_model_path.strip() or os.environ.get(
"DEERFLOW_BGE_M3_MODEL_PATH", ""
).strip()
if not raw:
return None
path = Path(raw).expanduser()
return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
def _local_model_files_ready(config: LocalWikiIndexConfig) -> bool:
path = _resolved_local_model_path(config)
return bool(
path
and path.is_dir()
and ((path / "onnx" / "model.onnx").is_file() or (path / "model.onnx").is_file())
)
def _embedding_provider_ready(config: LocalWikiIndexConfig) -> bool:
embedding = config.embedding
if embedding.provider == "local":
return _local_model_files_ready(config)
return bool(embedding.base_url)
def _schedule_embedding_retirement(app: FastAPI, embedding: Any) -> None:
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
if embedding not in retired:
retired.append(embedding)
app.state.llmwiki_retired_embeddings = retired
async def retire_after_grace() -> None:
retired_successfully = False
try:
await asyncio.sleep(_EMBEDDING_RETIREMENT_GRACE_SECONDS)
await embedding.retire()
retired_successfully = True
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to retire an old Local Wiki embedding runtime")
finally:
if retired_successfully:
current = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
app.state.llmwiki_retired_embeddings = [item for item in current if item is not embedding]
tasks: set[asyncio.Task[None]] = getattr(app.state, "llmwiki_retirement_tasks", None)
if tasks is None:
tasks = set()
app.state.llmwiki_retirement_tasks = tasks
task = asyncio.create_task(retire_after_grace(), name="llmwiki-embedding-retirement")
tasks.add(task)
task.add_done_callback(tasks.discard)
def _runtime_signature(
local_index: LocalWikiIndexConfig,
*,
resolved_llmwiki: Any,
encoder_mode: str,
) -> str:
payload = {
"local_index": local_index.model_dump(mode="json"),
# A directory can be mounted/unmounted without editing config.yaml.
# Readiness is part of the generation so services appear/disappear on
# the next lightweight scheduler poll instead of requiring a restart.
"local_model_files_ready": (
_local_model_files_ready(local_index)
if local_index.embedding.provider == "local"
else None
),
"encoder_mode": encoder_mode,
"weknora": {
"provider": resolved_llmwiki.provider,
"api_base_url": resolved_llmwiki.api_base_url,
"web_base_url": resolved_llmwiki.web_base_url,
"admin_email": resolved_llmwiki.admin_email,
"admin_password": resolved_llmwiki.admin_password,
"override_enabled": resolved_llmwiki.override_enabled,
},
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def rebuild_local_wiki_index_runtime(
app: FastAPI,
*,
force: bool = False,
) -> LocalWikiIndexConfig:
"""Refresh config and atomically replace all embedding-dependent services."""
lock = getattr(app.state, "llmwiki_runtime_lock", None)
if lock is None:
lock = asyncio.Lock()
app.state.llmwiki_runtime_lock = lock
async with lock:
source_config = get_app_config()
settings = load_system_settings()
effective_config = resolve_effective_local_wiki_index_config(
source_config,
settings.local_wiki_encoder,
)
resolved_llmwiki = get_resolved_llmwiki_runtime(source_config)
signature = _runtime_signature(
effective_config,
resolved_llmwiki=resolved_llmwiki,
encoder_mode=settings.local_wiki_encoder.mode,
)
current_signature = getattr(app.state, "llmwiki_runtime_signature", None)
if not force and current_signature == signature:
app.state.llmwiki_source_config = source_config
app.state.llmwiki_effective_config = effective_config
return effective_config
index_store = getattr(app.state, "llmwiki_index_store", None)
if index_store is None:
raise RuntimeError("Local Wiki index store is not initialized")
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
embedding_config = effective_config.embedding
provider_ready = _embedding_provider_ready(effective_config)
embedding_configured = bool(
embedding_config.enabled
and provider_ready
and embedding_config.model.strip()
)
old_embedding = getattr(app.state, "llmwiki_embedding", None)
reusable = (
old_embedding
if old_embedding is not None and old_embedding.config == embedding_config
else None
)
new_embedding = None
if embedding_configured:
new_embedding = reusable or StrictWikiEmbeddingClient(embedding_config)
new_cache = None
new_search = None
new_sync = None
new_retrieval = None
weknora_client = (
build_weknora_client(resolved_llmwiki)
if resolved_llmwiki.weknora_enabled
else None
)
if effective_config.enabled and new_embedding is not None:
new_cache = WikiVectorCache(
index_store,
max_knowledge_bases=effective_config.cache_max_knowledge_bases,
revision_check_seconds=effective_config.cache_revision_check_seconds,
)
new_search = WikiVectorSearchService(
effective_config,
new_embedding,
new_cache,
)
if weknora_client is not None:
new_sync = WikiSyncService(
effective_config,
index_store,
new_embedding,
new_cache,
weknora_client,
)
from app.gateway.knowledge_transfer import sync_to_global
async def on_wiki_vectorized(mapping):
return await sync_to_global(app, mapping)
new_sync.on_completed = on_wiki_vectorized
if weknora_client is not None or new_search is not None:
new_retrieval = WikiRetrievalService(
index_store,
vector_search=(new_search if effective_config.internal_search_enabled else None),
client=weknora_client,
)
app.state.llmwiki_source_config = source_config
app.state.llmwiki_effective_config = effective_config
app.state.llmwiki_embedding = new_embedding
app.state.llmwiki_vector_cache = new_cache
app.state.llmwiki_vector_search = new_search
app.state.llmwiki_sync_service = new_sync
app.state.llmwiki_retrieval_service = new_retrieval
app.state.llmwiki_runtime_signature = signature
register_local_wiki_index_runtime(
store=index_store,
search=new_search,
sync=new_sync,
retrieval=new_retrieval,
)
if old_embedding is not None and old_embedding is not new_embedding:
# Give requests holding the prior service graph time to finish,
# then reject any stragglers and release the potentially multi-GB
# local worker instead of accumulating one process per upload.
_schedule_embedding_retirement(app, old_embedding)
return effective_config
async def close_local_wiki_index_runtime(app: FastAPI) -> None:
"""Close the current and safely-retired embedding clients at shutdown."""
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
retirement_tasks = list(getattr(app.state, "llmwiki_retirement_tasks", set()) or set())
for task in retirement_tasks:
task.cancel()
if retirement_tasks:
await asyncio.gather(*retirement_tasks, return_exceptions=True)
app.state.llmwiki_retirement_tasks = set()
current = getattr(app.state, "llmwiki_embedding", None)
seen: set[int] = set()
for embedding in [current, *retired]:
if embedding is None or id(embedding) in seen:
continue
seen.add(id(embedding))
await embedding.close()
app.state.llmwiki_retired_embeddings = []
def local_encoder_runtime_status(app: FastAPI) -> dict[str, object]:
"""Return one stable status contract for remote and local providers."""
source_config: AppConfig | None = getattr(app.state, "llmwiki_source_config", None)
if source_config is None:
source_config = app.state.config
configured = source_config.llmwiki.local_wiki_index.embedding
effective: LocalWikiIndexConfig = getattr(
app.state,
"llmwiki_effective_config",
source_config.llmwiki.local_wiki_index,
)
settings = load_system_settings().local_wiki_encoder
embedding = getattr(app.state, "llmwiki_embedding", None)
if embedding is not None:
status = dict(embedding.local_encoder_status())
else:
status = {
"provider": effective.embedding.provider,
"model": effective.embedding.model,
"dimensions": effective.embedding.dimensions,
"state": "idle",
"running": False,
"last_error": None,
"started_at": None,
"ready_at": None,
"offline_only": effective.embedding.provider == "local",
"model_path_configured": False,
"model_path_exists": False,
"model_files_ready": False,
"process_isolation": effective.embedding.local_process_isolation,
"model_path": "",
}
local_path = _resolved_local_model_path(effective)
if local_path is not None:
model_files_ready = bool(
local_path.is_dir()
and (
(local_path / "onnx" / "model.onnx").is_file()
or (local_path / "model.onnx").is_file()
)
)
status.update(
{
"model_path": str(local_path),
"model_path_configured": True,
"model_path_exists": local_path.is_dir(),
"model_files_ready": model_files_ready,
}
)
status.update(
{
"provider": effective.embedding.provider,
"configured_provider": configured.provider,
"mode": settings.mode,
"using_local": effective.embedding.provider == "local",
"offline_only": effective.embedding.provider == "local",
}
)
return status
__all__ = [
"close_local_wiki_index_runtime",
"local_encoder_runtime_status",
"rebuild_local_wiki_index_runtime",
"resolve_effective_local_wiki_index_config",
]
@@ -5,11 +5,13 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Coroutine
from time import monotonic
from typing import Any
from fastapi import FastAPI
logger = logging.getLogger(__name__)
_CONFIG_REFRESH_SECONDS = 10.0
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
@@ -25,12 +27,27 @@ def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]
return task
async def _sync_once(app: FastAPI) -> None:
async def _refresh_local_wiki_config(app: FastAPI):
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
return await rebuild_local_wiki_index_runtime(app)
def _auto_sync_ready(app: FastAPI, config: Any) -> bool:
return bool(
config.enabled
and config.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
and getattr(app.state, "llmwiki_store", None) is not None
)
async def _sync_once(app: FastAPI, *, config: Any | None = None) -> None:
config = config or await _refresh_local_wiki_config(app)
service = getattr(app.state, "llmwiki_sync_service", None)
mapping_store = getattr(app.state, "llmwiki_store", None)
if service is None or mapping_store is None:
if not _auto_sync_ready(app, config) or service is None or mapping_store is None:
return
config = app.state.config.llmwiki.local_wiki_index
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
semaphore = asyncio.Semaphore(config.sync_concurrency)
@@ -47,16 +64,42 @@ async def _sync_once(app: FastAPI) -> None:
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
logger.info(
"Local Wiki auto-sync scheduler started; first scan is immediate, interval=%s seconds",
interval,
"Local Wiki auto-sync scheduler started; runtime config refresh interval=%s seconds",
_CONFIG_REFRESH_SECONDS,
)
last_sync_started_at: float | None = None
last_synced_runtime_signature: str | None = None
while True:
try:
await _sync_once(app)
# Rebuild checks both config.yaml mtime and persisted runtime
# settings every poll, independently from the much longer sync
# cadence. Provider/URL changes therefore take effect promptly.
config = await _refresh_local_wiki_config(app)
if not _auto_sync_ready(app, config):
# A later disabled -> enabled transition is a new first valid
# configuration and should receive an immediate scan.
last_sync_started_at = None
last_synced_runtime_signature = None
else:
now = monotonic()
interval = float(config.sync_interval_seconds)
runtime_signature = getattr(
app.state,
"llmwiki_runtime_signature",
None,
)
if (
last_sync_started_at is None
or runtime_signature != last_synced_runtime_signature
or now - last_sync_started_at >= interval
):
sync_started_at = now
await _sync_once(app, config=config)
last_sync_started_at = sync_started_at
last_synced_runtime_signature = runtime_signature
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
await asyncio.sleep(interval)
await asyncio.sleep(_CONFIG_REFRESH_SECONDS)
@@ -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",
},
)
@@ -181,6 +181,7 @@ class KnowledgeBaseCreate(BaseModel):
description: str = Field(default="", max_length=2048)
type: Literal["document", "faq"] = "document"
wiki_enabled: bool = False
embedding_model_id: str | None = Field(default=None, min_length=1, max_length=255)
class KnowledgeBaseUpdate(BaseModel):
@@ -251,7 +252,9 @@ def _store(request: Request) -> LlmWikiStore:
def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None:
service = getattr(request.app.state, "llmwiki_sync_service", None)
config = request.app.state.config.llmwiki.local_wiki_index
config = getattr(request.app.state, "llmwiki_effective_config", None)
if config is None:
config = request.app.state.config.llmwiki.local_wiki_index
if service is not None and config.enabled and config.auto_sync and mapping.get("wiki_index_enabled", True):
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
@@ -300,6 +303,74 @@ def _raise_upstream(exc: WeKnoraError) -> None:
raise HTTPException(status_code=status, detail=str(exc)) from None
def _embedding_model_view(source: dict[str, Any]) -> dict[str, Any] | None:
"""Return the small, credential-free model shape needed by the create dialog."""
if str(source.get("type") or "").strip().lower() != "embedding":
return None
model_id = str(source.get("id") or "").strip()
if not model_id:
return None
status = str(source.get("status") or "active").strip().lower() or "active"
explicitly_available = source.get("available")
available = (
bool(explicitly_available)
if isinstance(explicitly_available, bool)
else status in {"active", "available", "enabled", "ready"}
)
parameters = source.get("parameters") if isinstance(source.get("parameters"), dict) else {}
def safe_text(*keys: str) -> str:
for key in keys:
value = source.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
return ""
nested_provider = parameters.get("provider")
provider = safe_text("provider", "provider_type")
if not provider and isinstance(nested_provider, str):
provider = nested_provider.strip()
if not provider:
provider = safe_text("source", "platform")
nested_model = parameters.get("model") or parameters.get("model_name")
model_name = safe_text("model_name", "model")
if not model_name and isinstance(nested_model, str):
model_name = nested_model.strip()
return {
"id": model_id,
"name": safe_text("display_name", "name", "model_name", "model") or model_name or model_id,
"model_name": model_name,
"provider": provider,
"status": status,
"is_default": source.get("is_default") is True,
"available": available,
}
async def _embedding_models(client: WeKnoraClient) -> list[dict[str, Any]]:
return [view for item in await client.list_models() if (view := _embedding_model_view(item)) is not None]
def _select_embedding_model(models: list[dict[str, Any]], requested_id: str | None) -> str:
available = [item for item in models if item["available"]]
if requested_id:
selected = next((item for item in models if item["id"] == requested_id), None)
if selected is None:
raise WeKnoraError("The selected WeKnora embedding model does not exist", status_code=400)
if not selected["available"]:
raise WeKnoraError("The selected WeKnora embedding model is unavailable", status_code=400)
return requested_id
selected = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
if selected is None:
raise WeKnoraError(
"WeKnora has no available embedding model; configure one in WeKnora before creating a knowledge base",
status_code=400,
)
return str(selected["id"])
def _json_response(payload: Any, *, status_code: int = 200) -> Response:
return Response(
content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
@@ -309,7 +380,10 @@ def _json_response(payload: Any, *, status_code: int = 200) -> Response:
def _runtime_for_iframe_proxy(request: Request | None = None):
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None) if request is not None else None)
source_config = None
if request is not None:
source_config = getattr(request.app.state, "llmwiki_source_config", None)
runtime = get_resolved_llmwiki_runtime(source_config)
if not runtime.weknora_enabled:
raise HTTPException(status_code=409, detail="WeKnora mode is not enabled")
if not runtime.web_base_url:
@@ -2485,7 +2559,9 @@ async def get_llmwiki_runtime() -> dict[str, Any]:
@router.get("/api/system-settings/llmwiki")
async def get_llmwiki_settings(request: Request) -> dict[str, Any]:
await _require_admin(request)
app_config = request.app.state.config
from deerflow.config import get_app_config
app_config = get_app_config()
runtime = get_resolved_llmwiki_runtime(app_config)
return {
**_runtime_or_legacy(),
@@ -2508,6 +2584,9 @@ async def update_llmwiki_settings(request: Request, body: LlmWikiSettingsUpdate)
raise HTTPException(status_code=422, detail=str(exc)) from None
current = load_system_settings()
save_system_settings(current.model_copy(update={"llmwiki": override}))
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
await rebuild_local_wiki_index_runtime(request.app, force=True)
return _runtime_or_legacy()
@@ -2516,13 +2595,16 @@ async def reset_llmwiki_settings(request: Request) -> dict[str, Any]:
await _require_admin(request)
current = load_system_settings()
save_system_settings(current.model_copy(update={"llmwiki": LlmWikiRuntimeOverride()}))
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
await rebuild_local_wiki_index_runtime(request.app, force=True)
return _runtime_or_legacy()
@router.post("/api/system-settings/llmwiki/test")
async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) -> dict[str, Any]:
await _require_admin(request)
runtime = get_resolved_llmwiki_runtime(request.app.state.config)
runtime = get_resolved_llmwiki_runtime()
if body.api_base_url is not None:
try:
candidate = normalize_service_url(body.api_base_url)
@@ -2677,11 +2759,13 @@ def _public_local_index_summary(
state: dict[str, Any] | None,
) -> dict[str, Any]:
app_state = request.app.state
local_config = getattr(
getattr(getattr(app_state, "config", None), "llmwiki", None),
"local_wiki_index",
None,
)
local_config = getattr(app_state, "llmwiki_effective_config", None)
if local_config is None:
local_config = getattr(
getattr(getattr(app_state, "config", None), "llmwiki", None),
"local_wiki_index",
None,
)
enabled = bool(getattr(local_config, "enabled", False))
current = dict(state or {})
state_name = str(current.get("state") or "not_synced")
@@ -3000,6 +3084,21 @@ async def list_knowledge_bases(
return {"knowledge_bases": knowledge_bases}
@router.get("/api/llmwiki/embedding-models")
async def list_embedding_models(request: Request) -> dict[str, Any]:
"""List selectable WeKnora embedding models without exposing model secrets."""
await _actor(request)
try:
models = await _embedding_models(_client_or_503())
except WeKnoraError as exc:
_raise_upstream(exc)
available = [item for item in models if item["available"]]
default_model = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
default_model_id = str(default_model["id"]) if default_model is not None else None
return {"models": models, "default_model_id": default_model_id}
@router.get("/api/llmwiki/agents/{agent_id}/knowledge-bases")
async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[str, Any]:
"""Return the agent-bound bases still visible to the current DeerFlow user."""
@@ -3038,11 +3137,16 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME:
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
try:
embedding_model_id = _select_embedding_model(
await _embedding_models(client),
body.embedding_model_id,
)
remote = await client.create_knowledge_base(
name=body.name.strip(),
description=body.description,
kb_type=body.type,
wiki_enabled=body.wiki_enabled,
embedding_model_id=embedding_model_id,
)
except WeKnoraError as exc:
_raise_upstream(exc)
@@ -13,6 +13,10 @@ from fastapi import APIRouter, File, Form, HTTPException, Query, Request, Upload
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from app.gateway.llmwiki_index_runtime import (
local_encoder_runtime_status,
rebuild_local_wiki_index_runtime,
)
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
from deerflow.config.runtime_paths import resolve_path, runtime_home
from deerflow.config.system_settings import load_system_settings, save_system_settings
@@ -53,8 +57,8 @@ async def _actor(request: Request) -> tuple[str, bool]:
return str(user.id), getattr(user, "system_role", None) == "admin"
def _services(request: Request):
config = request.app.state.config.llmwiki.local_wiki_index
async def _services(request: Request):
config = await rebuild_local_wiki_index_runtime(request.app)
if not config.enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INDEX_DISABLED", "message": "Local Wiki index is disabled"})
search = getattr(request.app.state, "llmwiki_vector_search", None)
@@ -65,12 +69,12 @@ def _services(request: Request):
def _local_encoder(request: Request):
config = request.app.state.config.llmwiki.local_wiki_index.embedding
embedding = getattr(request.app.state, "llmwiki_embedding", None)
if config.provider != "local":
effective = getattr(request.app.state, "llmwiki_effective_config", None)
if effective is None or effective.embedding.provider != "local":
raise HTTPException(status_code=409, detail={"code": "EMBEDDING_NOT_LOCAL", "message": "当前 embedding provider 不是 local"})
embedding = getattr(request.app.state, "llmwiki_embedding", None)
if embedding is None:
raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器未配置或未启用"})
raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器目录未配置、无效或尚未就绪"})
return embedding
@@ -107,33 +111,35 @@ def _copy_encoder_uploads(files: list[UploadFile], paths: list[str], staging: Pa
return total
def _activate_uploaded_encoder(staging: Path) -> Path:
model_root = (runtime_home() / "models").resolve()
def _activate_uploaded_encoder(staging: Path, model_revision: str) -> Path:
model_root = (runtime_home() / "models" / "bge-m3-revisions").resolve()
model_root.mkdir(parents=True, exist_ok=True)
active = (model_root / "bge-m3").resolve()
active = (model_root / model_revision).resolve()
if active.parent != model_root:
raise RuntimeError("编码器目标目录越界")
backup = (model_root / f".bge-m3-backup-{uuid4().hex}").resolve()
if active.exists():
active.replace(backup)
try:
staging.replace(active)
except Exception:
if backup.exists() and not active.exists():
backup.replace(active)
raise
if backup.exists():
shutil.rmtree(backup)
raise RuntimeError("编码器版本目录已存在")
staging.replace(active)
return active
async def _save_encoder_path(request: Request, path: Path) -> dict[str, object]:
embedding = _local_encoder(request)
async def _save_encoder_path(
request: Request,
path: Path,
*,
model_revision: str | None = None,
) -> dict[str, object]:
current = load_system_settings()
encoder_settings = current.local_wiki_encoder.model_copy(update={"model_path": str(path)})
encoder_settings = current.local_wiki_encoder.model_copy(
update={
"mode": "local",
"model_path": str(path),
"model_revision": model_revision or uuid4().hex,
}
)
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
await embedding.reset_local_encoder(model_path=str(path))
return embedding.local_encoder_status()
await rebuild_local_wiki_index_runtime(request.app, force=True)
return local_encoder_runtime_status(request.app)
@router.get("/local-encoder/status")
@@ -141,7 +147,8 @@ async def local_encoder_status(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
return _local_encoder(request).local_encoder_status()
await rebuild_local_wiki_index_runtime(request.app)
return local_encoder_runtime_status(request.app)
@router.post("/local-encoder/start", status_code=202)
@@ -149,8 +156,10 @@ async def start_local_encoder(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
await rebuild_local_wiki_index_runtime(request.app)
try:
return _local_encoder(request).start_local_encoder()
_local_encoder(request).start_local_encoder()
return local_encoder_runtime_status(request.app)
except ValueError as exc:
raise HTTPException(status_code=409, detail={"code": "LOCAL_ENCODER_START_REJECTED", "message": str(exc)}) from None
@@ -163,6 +172,20 @@ async def configure_local_encoder(request: Request, body: LocalEncoderConfigRequ
return await _save_encoder_path(request, _validated_encoder_path(body.model_path))
@router.post("/local-encoder/use-configured")
async def use_configured_encoder(request: Request) -> dict[str, object]:
"""Restore the embedding provider declared in config.yaml without restart."""
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
current = load_system_settings()
encoder_settings = current.local_wiki_encoder.model_copy(update={"mode": "configured"})
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
await rebuild_local_wiki_index_runtime(request.app, force=True)
return local_encoder_runtime_status(request.app)
@router.post("/local-encoder/upload")
async def upload_local_encoder(
request: Request,
@@ -191,9 +214,17 @@ async def upload_local_encoder(
try:
uploaded_bytes = await asyncio.to_thread(_copy_encoder_uploads, files, paths, staging)
_validated_encoder_path(str(staging))
await _local_encoder(request).reset_local_encoder()
active = await asyncio.to_thread(_activate_uploaded_encoder, staging)
status = await _save_encoder_path(request, active)
model_revision = uuid4().hex
active = await asyncio.to_thread(
_activate_uploaded_encoder,
staging,
model_revision,
)
status = await _save_encoder_path(
request,
active,
model_revision=model_revision,
)
return {**status, "uploaded_files": len(files), "uploaded_bytes": uploaded_bytes}
except HTTPException:
raise
@@ -224,7 +255,7 @@ async def _authorized_mappings(request: Request, mapping_ids: list[str]) -> tupl
@router.post("/wiki-vector-search")
async def wiki_vector_search(request: Request, body: WikiVectorSearchRequest) -> dict[str, Any]:
config, search, _ = _services(request)
config, search, _ = await _services(request)
if not config.internal_search_enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INTERNAL_SEARCH_DISABLED", "message": "Local Wiki search is not enabled"})
rows, user_id, is_admin = await _authorized_mappings(request, body.knowledge_base_ids)
@@ -250,7 +281,7 @@ async def local_wiki_page(
include_provenance: bool = Query(default=False),
include_archived: bool = Query(default=False),
) -> dict[str, Any]:
_, _, index_store = _services(request)
_, _, index_store = await _services(request)
user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None:
@@ -292,7 +323,7 @@ async def wiki_index_status(request: Request) -> dict[str, Any]:
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
config = request.app.state.config.llmwiki.local_wiki_index
config = await rebuild_local_wiki_index_runtime(request.app)
index_store = getattr(request.app.state, "llmwiki_index_store", None)
if index_store is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
@@ -352,7 +383,7 @@ async def _run_one_sync(app, mapping: dict[str, Any], *, force: bool) -> None:
@router.post("/wiki-index/sync", status_code=202)
async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
_services(request)
await _services(request)
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
@@ -363,7 +394,7 @@ async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dic
@router.post("/wiki-index/rebuild", status_code=202)
async def rebuild_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
_, _, index_store = _services(request)
_, _, index_store = await _services(request)
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
@@ -388,6 +419,7 @@ async def rebuild_one_wiki_index(request: Request, mapping_id: str, reason: str
@router.get("/knowledge-bases/{mapping_id}/wiki-index-status")
async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) -> dict[str, Any]:
config = await rebuild_local_wiki_index_runtime(request.app)
user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None:
@@ -430,7 +462,7 @@ async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) ->
return {
**state,
"knowledge_base_id": mapping_id,
"local_index_enabled": bool(request.app.state.config.llmwiki.local_wiki_index.enabled),
"local_index_enabled": bool(config.enabled),
"wiki_index_enabled": mapping.get("wiki_index_enabled", True),
"fully_vectorized": fully_vectorized,
"retrieval_mode": "vector" if fully_vectorized else "wiki_api",
@@ -448,7 +480,7 @@ async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) ->
@router.post("/knowledge-bases/{mapping_id}/vectorize", status_code=202)
async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
config = request.app.state.config.llmwiki.local_wiki_index
config = await rebuild_local_wiki_index_runtime(request.app)
if not config.enabled:
raise HTTPException(
status_code=409,
@@ -71,6 +71,10 @@ class ChainSeatSchema(BaseModel):
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
position_id: str | None = Field(default=None, max_length=64)
# 该席位提交过程产物时传给外部任务系统的 position 值(8BF 会商页用)。
# 缺省 None = 提交时回退 0。保存在既有 seats JSON 中,无需迁移;显式声明
# 可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
artifact_position: int | None = None
# 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段;
# 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。
job_responsibilities: str | None = Field(default=None, max_length=4000)
+33 -5
View File
@@ -33,6 +33,26 @@ concurrency:
fail_open: true
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
display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI
@@ -59,16 +79,24 @@ models:
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
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: false
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: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
@@ -167,7 +195,7 @@ models:
# display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7
# api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# api_key: sk-cp-mXNcPkl81WAERsVaVOCj5r-F3LQv8kdtMWzx_U4RNAJejJKtK_JB2mHLFqBkBYvVDb8OPZmxJgiuctDHhDImvkHvJqGj_RvvlTaCzFvhwIvuHT8jm46niBY
# base_url: https://api.minimaxi.com/v1
# max_tokens: 4096
# temperature: 1.0
@@ -468,7 +496,7 @@ checkpointer:
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
@@ -597,7 +625,7 @@ llmwiki:
enabled: true
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
frontend_base_url: "http://127.0.0.1:5174"
auto_sync: false
auto_sync: true
sync_interval_seconds: 30
database_batch_size: 50
embedding:
@@ -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`).
**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**:
Some models support "thinking" mode for complex reasoning:
@@ -270,21 +291,21 @@ skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
```
`skills.es_query_routing.enabled` controls whether the routing rule is injected
into the overall agent system prompt. Edit `prompt` to tune the matching boundary
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
```
`skills.es_query_routing.enabled` controls whether the routing rule is injected
into the overall agent system prompt. Edit `prompt` to tune the matching boundary
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
**How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/`
@@ -19,7 +19,7 @@ The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a s
LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this:
- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled).
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the hidden summary is merged into LangChain's dedicated `ModelRequest.system_message` slot and the ordinary message list is replaced with the recent tail. This guarantees exactly one leading system message even for strict Qwen-style or generic `langchain_chat` adapters. **Nothing is written back to state.**
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
- **Threshold-first**: while the full current conversation is still under the trigger, the request is passed through untouched — no cache lookup, no summary substitution. Compression only ever engages once the real context crosses the configured trigger.
@@ -185,7 +185,7 @@ The default LangChain prompt instructs the model to:
- Messages to summarize (older messages beyond the `keep` threshold)
- Messages to preserve (recent messages within the `keep` threshold)
4. **Summary Generation**: The model generates a concise summary of the older messages
5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
5. **Context Replacement (transient)**: The request **handed to the model for this call** is rebuilt with one system message (agent prompt + hidden summary) and the recent non-system tail. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
@@ -202,12 +202,14 @@ The middleware intelligently preserves message context:
- **Recent Messages**: Always kept intact based on `keep` configuration
- **AI/Tool Pairs**: Never split - if a cutoff point falls within tool messages, the system adjusts to keep the entire AI + Tool message sequence together
- **Summary Format**: Summary is injected as a HumanMessage with the format:
```
Here is a summary of the conversation to date:
[Generated summary text]
```
- **Summary Format**: Summary is merged into the leading SystemMessage with the format:
```
Internal conversation summary for continuity only...
<internal_context_summary>
[generated summary text]
</internal_context_summary>
```
## Best Practices
@@ -329,7 +331,8 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
- Configuration is loaded once at startup
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
- The per-thread summary cache lives in a **process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded), **not** on the middleware instance. `make_lead_agent` rebuilds a fresh middleware every run, so an instance cache would be cold each turn and re-run a blocking summary LLM call on every turn of a long conversation; the module-level cache keyed by `thread_id` lets a summary survive across turns. It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
- The per-thread summary cache lives in a **process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded), **not** on the middleware instance. `make_lead_agent` rebuilds a fresh middleware every run, so an instance cache would be cold each turn and re-run a blocking summary LLM call on every turn of a long conversation; the module-level cache keyed by `thread_id` lets a summary survive across turns. It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct.
- Compression normalizes LangChain's split request representation before invoking the next middleware/model: the dedicated `system_message` contains all system context exactly once, while `messages` contains no `SystemMessage`. This makes the fix independent of the configured provider adapter.
## Example Configurations
@@ -40,77 +40,77 @@ logger = logging.getLogger(__name__)
WRITING_SETUP_MAX_ROUNDS = 8
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
# 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".strip()
def _enforce_coordinator_only_tool_exclusions(
agent_id: str | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return excluded_tools
effective = list(excluded_tools or [])
if "agent_orchestration" not in effective:
effective.append("agent_orchestration")
return effective
def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return system_prompt
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
def _filter_available_skills_for_excluded_tools(
available_skills: list[str] | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
if not excluded_tools:
return available_skills
excluded = {name.strip().lower() for name in excluded_tools if name}
blocked = {
skill_name
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
if tool_name in excluded
for skill_name in skill_names
}
if not blocked:
return available_skills
if available_skills is None:
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
try:
from deerflow.skills.storage import get_or_new_skill_storage
available_skills = [
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
]
except Exception: # noqa: BLE001
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
available_skills = []
return [name for name in available_skills if name.strip().lower() not in blocked]
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
def _log_es_query_routing_registration(
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
# 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".strip()
def _enforce_coordinator_only_tool_exclusions(
agent_id: str | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return excluded_tools
effective = list(excluded_tools or [])
if "agent_orchestration" not in effective:
effective.append("agent_orchestration")
return effective
def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return system_prompt
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
def _filter_available_skills_for_excluded_tools(
available_skills: list[str] | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
if not excluded_tools:
return available_skills
excluded = {name.strip().lower() for name in excluded_tools if name}
blocked = {
skill_name
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
if tool_name in excluded
for skill_name in skill_names
}
if not blocked:
return available_skills
if available_skills is None:
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
try:
from deerflow.skills.storage import get_or_new_skill_storage
available_skills = [
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
]
except Exception: # noqa: BLE001
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
available_skills = []
return [name for name in available_skills if name.strip().lower() not in blocked]
def _log_es_query_routing_registration(
*,
system_prompt: str,
app_config: AppConfig,
@@ -401,9 +401,9 @@ def _build_middlewares(
if summarization_middleware is not None:
middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False)
is_report_structure_setup = cfg.get("is_report_structure_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
@@ -433,7 +433,9 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
memory_cfg = getattr(resolved_app_config, "memory", None)
memory_globally_enabled = memory_cfg is not None and getattr(memory_cfg, "enabled", False)
if memory_globally_enabled and not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
@@ -574,10 +576,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
disable_tools = bool(cfg.get("disable_tools", False))
subagent_enabled = cfg.get("subagent_enabled", False)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False)
disable_tools = bool(cfg.get("disable_tools", False))
subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
@@ -603,11 +605,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if not is_writing_mode_report_path(writing_artifact_path):
writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
agent_id,
cfg.get("excluded_tools") or None,
)
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
agent_id,
cfg.get("excluded_tools") or None,
)
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None
@@ -656,14 +658,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills
runtime_available_skills = merge_collector_skill_allowlist(
agent_id, runtime_available_skills, runtime_extra_skills(cfg)
)
runtime_available_skills = _filter_available_skills_for_excluded_tools(
runtime_available_skills,
requested_excluded_tools,
)
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
runtime_available_skills = merge_collector_skill_allowlist(
agent_id, runtime_available_skills, runtime_extra_skills(cfg)
)
runtime_available_skills = _filter_available_skills_for_excluded_tools(
runtime_available_skills,
requested_excluded_tools,
)
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
config["metadata"].update(
{
@@ -744,14 +746,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools(
model_name=model_name,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config,
)
tools=get_available_tools(
model_name=model_name,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config,
)
+ [setup_agent],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
system_prompt=apply_prompt_template(
@@ -771,19 +773,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Default lead agent (unchanged behavior)
is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = (
[]
if disable_tools
else get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config,
)
)
default_tools = (
[]
if disable_tools
else get_available_tools(
model_name=model_name,
groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config,
)
)
if agent_id == FORCED_RESEARCH_AGENT_ID:
default_tools = filter_forced_research_tools(default_tools)
if is_notebook_mode:
@@ -825,8 +827,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_mode=writing_mode,
writing_artifact_path=writing_artifact_path,
)
else:
system_prompt = apply_prompt_template(
else:
system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents,
agent_id=agent_id,
@@ -839,22 +841,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids,
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
)
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
if presentation_theme in {"dark", "light"}:
system_prompt += (
"\n\n【当前界面主题】\n"
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
"除非用户明确要求覆盖该主题。"
)
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
_log_es_query_routing_registration(
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
)
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
if presentation_theme in {"dark", "light"}:
system_prompt += (
"\n\n【当前界面主题】\n"
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
"除非用户明确要求覆盖该主题。"
)
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
_log_es_query_routing_registration(
system_prompt=system_prompt,
app_config=resolved_app_config,
agent_id=agent_id,
@@ -9,8 +9,8 @@ from functools import lru_cache
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings
from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names
@@ -487,7 +487,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
</subagent_system>"""
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
<ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
@@ -496,19 +496,19 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format>
"""
def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch."""
try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False
SYSTEM_PROMPT_TEMPLATE = """
"""
def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch."""
try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False
SYSTEM_PROMPT_TEMPLATE = """
<role>
You are {agent_name}, an internal super agent.
</role>
@@ -883,11 +883,16 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
return ""
try:
from deerflow.agents.memory.manager import build_memory_manager
from deerflow.config.memory_config import get_effective_memory_config
from deerflow.config.memory_config import get_effective_memory_config, get_memory_config
from deerflow.runtime.user_context import get_effective_user_id
if not get_memory_config().enabled:
return ""
user_id = get_effective_user_id()
config = get_effective_memory_config(user_id)
if not config.enabled or not config.injection.enabled:
return ""
manager = build_memory_manager(
user_id=user_id,
@@ -1547,13 +1552,13 @@ When a search, knowledge, skill, or MCP tool returns a result list:
if rag_mode_enabled
else ""
)
ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap
and not writing_mode
and not is_notebook_mode
and not is_scheduled_run
ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap
and not writing_mode
and not is_notebook_mode
and not is_scheduled_run
else ""
)
@@ -5,8 +5,9 @@ Unlike LangChain's stock ``SummarizationMiddleware`` (which runs in
to the checkpoint — wiping the original Q&A from both the model context *and*
the conversation the frontend renders), this middleware compresses the history
**transiently in ``wrap_model_call``**. The full original messages stay in state
(so the UI keeps showing the user's real conversation); only the message list
handed to the model for a single call is replaced with ``[summary, *recent]``.
(so the UI keeps showing the user's real conversation); only the request handed
to the model is replaced with a merged leading system context plus recent
messages.
The generated summary is cached per-thread so we don't pay an extra LLM call on
every model invocation: once a thread is summarized, the cached summary is reused
@@ -39,6 +40,8 @@ from langchain_core.messages import (
from langgraph.config import get_config
from langgraph.runtime import Runtime
from deerflow.models.message_ordering import normalize_model_request_messages
logger = logging.getLogger(__name__)
# Upper bound on the per-thread summary cache so a long-lived process serving
@@ -321,7 +324,14 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> ModelCallResult:
compressed = self._compressed_messages(request)
if compressed is not None:
request = request.override(messages=compressed)
system_message, messages = normalize_model_request_messages(
request.system_message,
compressed,
)
request = request.override(
system_message=system_message,
messages=messages,
)
return handler(request)
@override
@@ -332,7 +342,14 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> ModelCallResult:
compressed = await self._acompressed_messages(request)
if compressed is not None:
request = request.override(messages=compressed)
system_message, messages = normalize_model_request_messages(
request.system_message,
compressed,
)
request = request.override(
system_message=system_message,
messages=messages,
)
return await handler(request)
def _compressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
@@ -77,6 +77,7 @@ class LocalWikiEmbeddingConfig(BaseModel):
base_url: str = ""
api_key: str = Field(default="", repr=False)
local_model_path: str = ""
local_model_revision: str = ""
local_cache_dir: str = ""
local_process_isolation: bool = True
local_threads: int = Field(default=2, ge=1, le=32)
@@ -16,7 +16,7 @@
============
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True)
(仅白名单字段,不能覆盖 ``enabled``)
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
3. Pydantic 默认值
@@ -254,13 +254,12 @@ def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
有 per-user 记录时自动强制 enabled=True。
``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。
"""
base = _memory_config.model_dump()
recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
base["enabled"] = True
try:
return MemoryConfig(**base)
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
@@ -508,11 +508,23 @@ class ModelConcurrencySettings(BaseModel):
class LocalWikiEncoderSettings(BaseModel):
"""Runtime override for the administrator-managed offline Wiki encoder."""
mode: Literal["configured", "local"] = Field(
default="configured",
description=(
"Use the embedding provider from config.yaml, or explicitly override it "
"with the administrator-managed offline BGE-M3 encoder."
),
)
model_path: str = Field(
default="",
max_length=4096,
description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.",
)
model_revision: str = Field(
default="",
max_length=64,
description="Opaque revision changed whenever an offline model directory is activated.",
)
model_config = ConfigDict(extra="ignore")
@@ -607,7 +619,12 @@ def save_system_settings(settings: SystemSettings) -> SystemSettings:
path = _settings_path()
payload = settings.model_dump(mode="json")
with _lock:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
# Multiple Gateway workers share this file. A unique temporary name
# prevents their atomic replaces from racing over one common `.tmp`.
tmp = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
return settings
@@ -57,6 +57,9 @@ class StrictWikiEmbeddingClient:
self._local_started_at: str | None = None
self._local_ready_at: str | None = None
self._local_warmup_task: asyncio.Task[None] | None = None
self._lifecycle_condition = asyncio.Condition()
self._active_uses = 0
self._retired = False
@property
def dimensions(self) -> int | None:
@@ -64,7 +67,21 @@ class StrictWikiEmbeddingClient:
@property
def fingerprint(self) -> str:
raw = "\0".join((self.config.provider.strip().lower(), self.config.base_url.rstrip("/").lower(), self.config.model.strip(), str(self._resolved_dimensions or "auto")))
parts = [
self.config.provider.strip().lower(),
self.config.base_url.rstrip("/").lower(),
self.config.model.strip(),
str(self._resolved_dimensions or "auto"),
]
if self.config.provider == "local":
model_path = self._local_model_path()
parts.extend(
(
str(model_path) if model_path is not None else "",
self.config.local_model_revision,
)
)
raw = "\0".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def close(self) -> None:
@@ -73,6 +90,15 @@ class StrictWikiEmbeddingClient:
await self._client.aclose()
self._client = None
async def retire(self) -> None:
"""Reject future work, drain active calls, then release model resources."""
async with self._lifecycle_condition:
self._retired = True
async with self._lifecycle_condition:
await self._lifecycle_condition.wait_for(lambda: self._active_uses == 0)
await self.close()
async def reset_local_encoder(self, *, model_path: str | None = None) -> None:
"""Stop only the embedded worker and optionally switch its model path.
@@ -82,27 +108,80 @@ class StrictWikiEmbeddingClient:
"""
warmup = self._local_warmup_task
if warmup is not None and not warmup.done():
warmup.cancel()
# Local inference runs in a worker thread. Cancelling the asyncio
# wrapper does not stop that thread and could let it publish a new
# process/model after cleanup. Drain it before touching resources.
await asyncio.gather(warmup, return_exceptions=True)
self._local_warmup_task = None
process = self._local_process
self._local_process = None
if process is not None:
await asyncio.to_thread(self._stop_local_process, process)
self._local_model = None
self._local_state = "idle"
self._local_error = None
self._local_started_at = None
self._local_ready_at = None
if model_path is not None:
self.config = self.config.model_copy(update={"local_model_path": model_path})
# Wait for a non-warmup embedding batch to finish before replacing or
# stopping the local worker. This prevents an admin upload from
# breaking a concurrent vectorization request halfway through IPC.
async with self._local_lock:
process = self._local_process
self._local_process = None
if process is not None:
await self._run_thread_drained(self._stop_local_process, process)
self._local_model = None
self._local_state = "idle"
self._local_error = None
self._local_started_at = None
self._local_ready_at = None
if model_path is not None:
self.config = self.config.model_copy(update={"local_model_path": model_path})
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
return self._client
@staticmethod
async def _run_thread_drained(function, *args):
"""Do not release lifecycle locks while cancelled thread work still runs."""
worker = asyncio.create_task(asyncio.to_thread(function, *args))
cancelled = False
worker_error: BaseException | None = None
result = None
while not worker.done():
try:
result = await asyncio.shield(worker)
except asyncio.CancelledError:
# A caller can be cancelled more than once during shutdown.
# Swallow every cancellation until the non-cancellable thread
# has really stopped, then restore cancellation semantics.
cancelled = True
except BaseException as exc:
worker_error = exc
break
if worker.done() and worker_error is None:
try:
result = worker.result()
except BaseException as exc:
worker_error = exc
if cancelled:
raise asyncio.CancelledError from None
if worker_error is not None:
raise worker_error
return result
async def embed_texts(self, texts: list[str]) -> list[list[float]]:
async with self._lifecycle_condition:
if self._retired:
raise WikiEmbeddingError(
"Wiki embedding runtime was replaced; retry with the active provider",
category="runtime_retired",
retryable=True,
)
self._active_uses += 1
try:
return await self._embed_texts_active(texts)
finally:
async with self._lifecycle_condition:
self._active_uses -= 1
if self._active_uses == 0:
self._lifecycle_condition.notify_all()
async def _embed_texts_active(self, texts: list[str]) -> list[list[float]]:
if self.config.provider == "local":
if not texts:
return []
@@ -110,7 +189,10 @@ class StrictWikiEmbeddingClient:
self._local_state = "starting"
self._local_started_at = self._local_started_at or datetime.now(UTC).isoformat()
try:
values = await asyncio.to_thread(self._embed_local, texts)
# `to_thread` keeps running after its awaiting task is
# cancelled. Keep the lifecycle lease and local lock until
# the real inference call has stopped.
values = await self._run_thread_drained(self._embed_local, texts)
except Exception as exc:
self._local_state = "failed"
self._local_error = " ".join(str(exc).split())[:1000] or type(exc).__name__
@@ -452,6 +534,8 @@ class StrictWikiEmbeddingClient:
def start_local_encoder(self) -> dict[str, object]:
"""Warm the embedded CPU encoder without coupling it to app startup."""
if self._retired:
raise ValueError("当前本地编码器运行时已切换,请刷新状态后重试")
if self.config.provider != "local":
raise ValueError("当前 embedding provider 不是 local")
if not self.config.enabled:
@@ -11,7 +11,7 @@ from uuid import uuid4
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.integrations.weknora.client import WeKnoraClient
from deerflow.integrations.weknora.local_index.chunking import chunk_wiki_page
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
from deerflow.integrations.weknora.local_index.vector_codec import encode_vector
@@ -137,6 +137,8 @@ class WikiSyncService:
except Exception as exc:
if isinstance(exc, WikiSyncLeaseLost):
raise
if isinstance(exc, WikiEmbeddingError) and exc.category == "runtime_retired":
raise
failed += 1
try:
fallback = normalize_wiki_page(mapping_id, summary)
@@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
return disable_kwargs
# Qwen3.8 chat templates only accept these three effort names.
_QWEN_REASONING_EFFORT = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "xhigh",
"xhigh": "xhigh",
}
_OPENAI_THINKING_OFF_EXTRA_BODY = {
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def _is_vllm_provider(model_use: str) -> bool:
lowered = (model_use or "").lower()
return "vllm_provider" in lowered or lowered.endswith(":vllmchatmodel")
def _is_qwen38_model(model_config) -> bool:
blob = f"{getattr(model_config, 'name', '')} {getattr(model_config, 'model', '')}".lower()
return "qwen3.8" in blob
def _should_control_openai_thinking(model_config) -> bool:
"""Whether this OpenAI-compatible model needs explicit thinking on/off flags.
Qwen3.8 (and current vLLM reasoning parsers) default to thinking at ``xhigh``.
If DeerFlow does not send ``enable_thinking: false``, the first visible answer
token waits until that hidden reasoning finishes — third-party chat UIs that
disable thinking or stream the reasoning field therefore feel much faster.
"""
if getattr(model_config, "supports_thinking", False):
return True
if _is_vllm_provider(getattr(model_config, "use", "")):
return True
return _is_qwen38_model(model_config)
def _map_qwen_reasoning_effort(effort: object) -> str | None:
if effort is None:
return None
return _QWEN_REASONING_EFFORT.get(str(effort).strip().lower())
def _apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config: dict,
*,
thinking_enabled: bool,
reasoning_effort: object,
) -> None:
"""Inject vLLM/Qwen thinking flags when the UI toggle is on or off."""
if "extra_body" not in getattr(model_class, "model_fields", {}):
return
qwen_vllm = _is_vllm_provider(getattr(model_config, "use", "")) or _is_qwen38_model(model_config)
if thinking_enabled:
if not qwen_vllm:
return
enable_body: dict = {
"enable_thinking": True,
"chat_template_kwargs": {"enable_thinking": True},
}
mapped_effort = _map_qwen_reasoning_effort(reasoning_effort)
if mapped_effort:
enable_body["chat_template_kwargs"]["reasoning_effort"] = mapped_effort
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
enable_body,
)
return
if not _should_control_openai_thinking(model_config):
return
disable_body = (
{
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
}
if qwen_vllm
else _OPENAI_THINKING_OFF_EXTRA_BODY
)
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
disable_body,
)
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
"""Enable stream usage for OpenAI-compatible models unless explicitly configured.
@@ -127,10 +217,19 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter
model_settings_from_config["thinking"] = {"type": "disabled"}
requested_reasoning_effort = kwargs.get("reasoning_effort")
if not model_config.supports_reasoning_effort:
kwargs.pop("reasoning_effort", None)
model_settings_from_config.pop("reasoning_effort", None)
_apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config,
thinking_enabled=thinking_enabled,
reasoning_effort=requested_reasoning_effort,
)
# Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it).
@@ -139,15 +238,15 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
# (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the
# nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native
# ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``.
# Only models that explicitly opt into thinking should receive vendor-specific
# disable payloads. Generic OpenAI-compatible gateways (including LiteLLM)
# may reject these otherwise-unknown fields instead of forwarding them.
if (
force_disable_thinking
and not thinking_enabled
and model_config.supports_thinking
and "extra_body" in getattr(model_class, "model_fields", {})
):
# Only models that explicitly opt into thinking should receive vendor-specific
# disable payloads. Generic OpenAI-compatible gateways (including LiteLLM)
# may reject these otherwise-unknown fields instead of forwarding them.
if (
force_disable_thinking
and not thinking_enabled
and model_config.supports_thinking
and "extra_body" in getattr(model_class, "model_fields", {})
):
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
@@ -6,7 +6,6 @@ from typing import Any
from langchain_core.messages import BaseMessage, SystemMessage
_SYSTEM_SEPARATOR = "\n\n---\n\n"
@@ -35,7 +34,11 @@ def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessag
return messages
first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages))
if len(system_messages) == first_non_system_index:
# A number of strict chat templates accept *one* system message, and only
# at index zero. Two adjacent leading system messages are therefore not a
# valid no-op case: LangChain's agent prompt followed by DeerFlow's
# transient compaction summary must still be folded into one message.
if len(system_messages) == 1 and first_non_system_index == 1:
return messages
merged = system_messages[0].model_copy(
@@ -46,20 +49,38 @@ def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessag
return [merged, *(message for message in messages if not isinstance(message, SystemMessage))]
def normalize_model_request_messages(
system_message: SystemMessage | None,
messages: list[BaseMessage],
) -> tuple[SystemMessage | None, list[BaseMessage]]:
"""Normalize LangChain's split ``ModelRequest`` message representation.
``ModelRequest.messages`` is documented as excluding the system prompt.
Transient middleware (notably context compaction) can nevertheless place a
``SystemMessage`` in that list. Fold every such message into the dedicated
system slot so adapters without a DeerFlow-specific payload patch also
receive exactly one leading system message.
"""
if system_message is None and not any(isinstance(message, SystemMessage) for message in messages):
return None, messages
combined = [system_message, *messages] if system_message is not None else messages
normalized = normalize_langchain_messages(combined)
if normalized and isinstance(normalized[0], SystemMessage):
return normalized[0], normalized[1:]
return None, normalized
def _normalize_payload_message_list(messages: list[Any]) -> list[Any]:
system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"]
if not system_messages:
return messages
first_non_system_index = next(
(
idx
for idx, message in enumerate(messages)
if not (isinstance(message, dict) and message.get("role") == "system")
),
(idx for idx, message in enumerate(messages) if not (isinstance(message, dict) and message.get("role") == "system")),
len(messages),
)
if len(system_messages) == first_non_system_index:
if len(system_messages) == 1 and first_non_system_index == 1:
return messages
merged_system = dict(system_messages[0])
@@ -25,75 +25,76 @@ message that originally carried it:
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from collections.abc import Mapping
from typing import Any
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages
class OpenAICompatibleUpstreamError(RuntimeError):
"""An OpenAI-compatible gateway reported an error inside a HTTP 200 body.
Several compatible gateways do not use an HTTP error status for quota,
concurrency, or inference failures. Instead they return ``choices=null``
(or an empty streamed chunk) together with a vendor ``base_resp`` object.
LangChain raises a generic ``TypeError`` for the non-streaming shape and
treats the streaming shape as an empty successful response. Raising one
stable exception here lets DeerFlow's model middleware retry both paths.
"""
def __init__(self, *, code: Any, message: str | None = None) -> None:
self.code = code
detail = _bounded_error_message(message)
suffix = f": {detail}" if detail else ""
super().__init__(f"OpenAI-compatible upstream application error (code={code}){suffix}")
def _bounded_error_message(value: Any, *, limit: int = 500) -> str:
"""Return a log-safe, bounded one-line provider message."""
if not isinstance(value, str):
return ""
normalized = " ".join(value.split())
return normalized[:limit]
def _application_error(payload: Mapping[str, Any]) -> tuple[Any, str | None] | None:
"""Extract a non-success ``base_resp`` error from a compatible payload."""
raw = payload.get("base_resp") or payload.get("baseResp")
if not isinstance(raw, Mapping):
return None
code = raw.get("status_code")
if code is None:
code = raw.get("statusCode")
if code is None:
code = raw.get("code")
# MiniMax-compatible responses use 0 for success. Be conservative for
# gateways that put informational metadata in ``base_resp``.
normalized_code = str(code).strip().lower() if code is not None else ""
if normalized_code in {"", "0", "200", "ok", "success"}:
return None
message = raw.get("status_msg")
if message is None:
message = raw.get("statusMsg")
if message is None:
message = raw.get("message")
return code, message if isinstance(message, str) else None
def _raise_for_application_error(payload: Mapping[str, Any]) -> None:
error = _application_error(payload)
if error is not None:
code, message = error
raise OpenAICompatibleUpstreamError(code=code, message=message)
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
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 OpenAICompatibleUpstreamError(RuntimeError):
"""An OpenAI-compatible gateway reported an error inside a HTTP 200 body.
Several compatible gateways do not use an HTTP error status for quota,
concurrency, or inference failures. Instead they return ``choices=null``
(or an empty streamed chunk) together with a vendor ``base_resp`` object.
LangChain raises a generic ``TypeError`` for the non-streaming shape and
treats the streaming shape as an empty successful response. Raising one
stable exception here lets DeerFlow's model middleware retry both paths.
"""
def __init__(self, *, code: Any, message: str | None = None) -> None:
self.code = code
detail = _bounded_error_message(message)
suffix = f": {detail}" if detail else ""
super().__init__(f"OpenAI-compatible upstream application error (code={code}){suffix}")
def _bounded_error_message(value: Any, *, limit: int = 500) -> str:
"""Return a log-safe, bounded one-line provider message."""
if not isinstance(value, str):
return ""
normalized = " ".join(value.split())
return normalized[:limit]
def _application_error(payload: Mapping[str, Any]) -> tuple[Any, str | None] | None:
"""Extract a non-success ``base_resp`` error from a compatible payload."""
raw = payload.get("base_resp") or payload.get("baseResp")
if not isinstance(raw, Mapping):
return None
code = raw.get("status_code")
if code is None:
code = raw.get("statusCode")
if code is None:
code = raw.get("code")
# MiniMax-compatible responses use 0 for success. Be conservative for
# gateways that put informational metadata in ``base_resp``.
normalized_code = str(code).strip().lower() if code is not None else ""
if normalized_code in {"", "0", "200", "ok", "success"}:
return None
message = raw.get("status_msg")
if message is None:
message = raw.get("statusMsg")
if message is None:
message = raw.get("message")
return code, message if isinstance(message, str) else None
def _raise_for_application_error(payload: Mapping[str, Any]) -> None:
error = _application_error(payload)
if error is not None:
code, message = error
raise OpenAICompatibleUpstreamError(code=code, message=message)
class PatchedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
@@ -149,31 +150,31 @@ class PatchedChatOpenAI(ChatOpenAI):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_tool_call_signatures(payload_msg, orig_msg)
_restore_reasoning_content(payload_msg, orig_msg)
else:
# Fallback: match assistant-role entries positionally against AIMessages.
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg)
normalize_chat_payload_messages(payload)
return payload
else:
# Fallback: match assistant-role entries positionally against AIMessages.
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg)
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
# Some compatible gateways return a vendor error event followed by a
# normal-looking usage chunk, all under HTTP 200. Detect the first
# event before LangChain converts it into an empty AIMessageChunk.
_raise_for_application_error(chunk)
generation_chunk = super()._convert_chunk_to_generation_chunk(
chunk,
default_chunk_class,
normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk(
self,
chunk: dict,
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
# Some compatible gateways return a vendor error event followed by a
# normal-looking usage chunk, all under HTTP 200. Detect the first
# event before LangChain converts it into an empty AIMessageChunk.
_raise_for_application_error(chunk)
generation_chunk = super()._convert_chunk_to_generation_chunk(
chunk,
default_chunk_class,
base_generation_info,
)
if generation_chunk is None:
@@ -182,37 +183,43 @@ class PatchedChatOpenAI(ChatOpenAI):
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = _append_reasoning_content(
if isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = apply_reasoning_from_payload(
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
def _create_chat_result(
self,
response: dict | Any,
generation_info: dict | None = None,
) -> ChatResult:
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
response_dict = response if isinstance(response, dict) else response.model_dump()
_raise_for_application_error(response_dict)
result = super()._create_chat_result(response, generation_info)
choices = response_dict.get("choices", [])
) -> ChatResult:
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
response_dict = response if isinstance(response, dict) else response.model_dump()
_raise_for_application_error(response_dict)
result = super()._create_chat_result(response, generation_info)
choices = response_dict.get("choices", [])
generations: list[ChatGeneration] = []
for index, generation in enumerate(result.generations):
message = generation.message
choice = choices[index] if index < len(choices) 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 reasoning_content and isinstance(message, AIMessage):
message = _append_reasoning_content(message, reasoning_content)
generation = ChatGeneration(
message=message,
generation_info=generation.generation_info,
if isinstance(message, AIMessage):
updated = apply_reasoning_from_payload(
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(
message=message,
generation_info=generation.generation_info,
)
generations.append(generation)
return ChatResult(generations=generations, llm_output=result.llm_output)
@@ -260,33 +267,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
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
assistant turn to include the original ``reasoning_content`` in subsequent
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does
not always serialize it back into ``messages``.
assistant turn to include the original thinking field in subsequent
requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
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")
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
restore_reasoning_fields(payload_msg, orig_msg)
def _extract_reasoning_content(payload: dict | None) -> str | None:
"""Extract reasoning text from provider payload delta/message objects."""
if not isinstance(payload, dict):
return None
reasoning_content = payload.get("reasoning_content")
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None
return extract_reasoning_text(payload)
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
additional_kwargs = dict(message.additional_kwargs)
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})
return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
@@ -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
@@ -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
be echoed back on subsequent turns.
This provider preserves ``reasoning`` on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
This provider preserves ``reasoning`` (newer vLLM) and
``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any, cast
@@ -32,11 +33,12 @@ from langchain_core.messages import (
)
from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
"""Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``.
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
def _reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
if isinstance(reasoning, str):
return reasoning
from deerflow.models.reasoning_fields import reasoning_to_text
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)
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
return reasoning_to_text(reasoning)
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"] = ""
additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning")
if reasoning is not None:
additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
additional_kwargs["reasoning_content"] = reasoning_text
seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
tool_call_chunks = []
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:
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
reasoning = orig_msg.additional_kwargs.get("reasoning")
if reasoning is None:
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
restore_reasoning_fields(payload_msg, orig_msg)
class VllmChatModel(ChatOpenAI):
@@ -184,14 +156,14 @@ class VllmChatModel(ChatOpenAI):
for payload_msg, orig_msg in zip(payload_messages, original_messages):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_field(payload_msg, orig_msg)
else:
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg)
normalize_chat_payload_messages(payload)
return payload
else:
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg)
normalize_chat_payload_messages(payload)
return payload
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
"""Preserve vLLM reasoning on non-streaming responses."""
@@ -204,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
message = generation.message
if not isinstance(message, AIMessage):
continue
reasoning = choice.get("message", {}).get("reasoning")
if reasoning is None:
continue
message.additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
message.additional_kwargs["reasoning_content"] = reasoning_text
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
generation.message = apply_reasoning_from_payload(
message,
choice_message if isinstance(choice_message, dict) else None,
append=False,
)
return result
@@ -2,7 +2,7 @@ import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages
from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages, normalize_model_request_messages
from deerflow.models.patched_openai import PatchedChatOpenAI
@@ -49,6 +49,37 @@ def test_normalize_langchain_messages_moves_late_systems_before_history():
assert normalized[1].content == "user 0"
def test_normalize_chat_payload_messages_merges_adjacent_leading_systems():
payload = {
"messages": [
{"role": "system", "content": "agent prompt"},
{"role": "system", "content": "compaction summary"},
{"role": "user", "content": "continue"},
]
}
normalize_chat_payload_messages(payload)
assert [message["role"] for message in payload["messages"]] == ["system", "user"]
assert payload["messages"][0]["content"] == "agent prompt\n\n---\n\ncompaction summary"
def test_normalize_model_request_messages_folds_compaction_summary_into_system_slot():
system_message, messages = normalize_model_request_messages(
SystemMessage(content="agent prompt"),
[
SystemMessage(content="compaction summary"),
HumanMessage(content="continue"),
],
)
assert system_message is not None
assert system_message.content == "agent prompt\n\n---\n\ncompaction summary"
assert len(messages) == 1
assert isinstance(messages[0], HumanMessage)
assert all(not isinstance(message, SystemMessage) for message in messages)
@pytest.mark.parametrize("turns", [12, 25, 50])
def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns):
model = PatchedChatOpenAI(
@@ -0,0 +1,283 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Event
from types import SimpleNamespace
import pytest
import yaml
from fastapi import FastAPI
from starlette.requests import Request
from app.gateway import llmwiki_index_runtime
from app.gateway.routers import llmwiki_index
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig, LocalWikiIndexConfig
from deerflow.config.system_settings import LocalWikiEncoderSettings, SystemSettings
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError
def _source_config(embedding: LocalWikiEmbeddingConfig):
return SimpleNamespace(
llmwiki=SimpleNamespace(
local_wiki_index=LocalWikiIndexConfig(enabled=True, embedding=embedding)
)
)
def test_runtime_mode_can_switch_remote_config_to_uploaded_local_model(tmp_path: Path) -> None:
configured = LocalWikiEmbeddingConfig(
provider="openai_compatible",
model="remote-embedding",
base_url="https://embedding.example.test/v1",
dimensions=1536,
)
source = _source_config(configured)
normal = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
source,
LocalWikiEncoderSettings(mode="configured"),
)
assert normal.embedding.provider == "openai_compatible"
assert normal.embedding.model == "remote-embedding"
assert normal.embedding.dimensions == 1536
local = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
source,
LocalWikiEncoderSettings(
mode="local",
model_path=str(tmp_path),
model_revision="revision-2",
),
)
assert local.embedding.provider == "local"
assert local.embedding.model == "bge-embedding-m3"
assert local.embedding.dimensions == 1024
assert local.embedding.local_model_path == str(tmp_path)
assert local.embedding.local_model_revision == "revision-2"
assert local.embedding.base_url == ""
assert local.embedding.api_key == ""
def test_local_model_revision_changes_embedding_fingerprint(tmp_path: Path) -> None:
common = {
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"local_model_path": str(tmp_path),
}
first = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_revision="first")
)
second = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_revision="second")
)
assert first.fingerprint != second.fingerprint
def test_local_model_path_changes_embedding_fingerprint(tmp_path: Path) -> None:
first_path = tmp_path / "revision-a"
second_path = tmp_path / "revision-b"
common = {
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"local_model_revision": "same-revision",
}
first = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_path=str(first_path))
)
second = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_path=str(second_path))
)
assert first.fingerprint != second.fingerprint
def test_uploaded_local_mode_always_uses_bge_m3_contract(tmp_path: Path) -> None:
configured = LocalWikiEmbeddingConfig(
provider="local",
model="another-local-model",
local_model_path=str(tmp_path / "configured"),
dimensions=384,
)
effective = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
_source_config(configured),
LocalWikiEncoderSettings(
mode="local",
model_path=str(tmp_path / "uploaded"),
model_revision="uploaded-revision",
),
)
assert effective.embedding.provider == "local"
assert effective.embedding.model == "bge-embedding-m3"
assert effective.embedding.dimensions == 1024
assert effective.embedding.local_model_path == str(tmp_path / "uploaded")
def test_local_provider_never_uses_residual_base_url_as_readiness(tmp_path: Path) -> None:
config = LocalWikiIndexConfig(
enabled=True,
embedding=LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
local_model_path=str(tmp_path / "missing"),
base_url="https://stale.example.test/v1",
dimensions=1024,
),
)
assert llmwiki_index_runtime._embedding_provider_ready(config) is False
@pytest.mark.asyncio
async def test_retired_embedding_rejects_future_work() -> None:
client = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(
provider="openai",
model="text-embedding-3-small",
base_url="https://api.openai.com/v1",
dimensions=1536,
)
)
await client.retire()
with pytest.raises(WikiEmbeddingError) as exc_info:
await client.embed_query("retry me")
assert getattr(exc_info.value, "category", None) == "runtime_retired"
@pytest.mark.asyncio
async def test_cancelled_local_embedding_drains_worker_thread_before_releasing_lock(
monkeypatch,
) -> None:
client = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
dimensions=2,
)
)
started = Event()
release = Event()
def blocking_embed(_texts):
started.set()
release.wait(timeout=5)
return [[1.0, 0.0]]
monkeypatch.setattr(client, "_embed_local", blocking_embed)
embedding_task = asyncio.create_task(client.embed_texts(["text"]))
assert await asyncio.to_thread(started.wait, 1)
embedding_task.cancel()
await asyncio.sleep(0)
assert not embedding_task.done()
assert client._active_uses == 1
embedding_task.cancel()
await asyncio.sleep(0)
assert not embedding_task.done()
assert client._active_uses == 1
release.set()
with pytest.raises(asyncio.CancelledError):
await embedding_task
assert client._active_uses == 0
@pytest.mark.asyncio
async def test_status_is_available_for_remote_provider(monkeypatch) -> None:
configured = LocalWikiEmbeddingConfig(
provider="openai",
model="text-embedding-3-small",
base_url="https://api.openai.com/v1",
dimensions=1536,
)
app = FastAPI()
app.state.config = _source_config(configured)
app.state.llmwiki_effective_config = app.state.config.llmwiki.local_wiki_index
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(configured)
request = Request({"type": "http", "headers": [], "app": app})
async def admin(_request):
return "admin", True
async def unchanged(_app, *, force=False):
return _app.state.llmwiki_effective_config
monkeypatch.setattr(llmwiki_index, "_actor", admin)
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", unchanged)
monkeypatch.setattr(
llmwiki_index_runtime,
"load_system_settings",
lambda: SystemSettings(
local_wiki_encoder=LocalWikiEncoderSettings(mode="configured")
),
)
result = await llmwiki_index.local_encoder_status(request)
assert result["provider"] == "openai"
assert result["configured_provider"] == "openai"
assert result["mode"] == "configured"
assert result["using_local"] is False
assert result["state"] == "idle"
@pytest.mark.asyncio
async def test_local_path_save_and_restore_persist_mode_and_rebuild(
monkeypatch,
tmp_path: Path,
) -> None:
model_dir = tmp_path / "bge-m3"
(model_dir / "onnx").mkdir(parents=True)
(model_dir / "onnx" / "model.onnx").write_bytes(b"onnx")
app = FastAPI()
request = Request({"type": "http", "headers": [], "app": app})
saved: list[SystemSettings] = []
current = SystemSettings()
rebuilds: list[bool] = []
async def admin(_request):
return "admin", True
async def rebuilt(_app, *, force=False):
rebuilds.append(force)
return LocalWikiIndexConfig()
monkeypatch.setattr(llmwiki_index, "_actor", admin)
monkeypatch.setattr(llmwiki_index, "load_system_settings", lambda: saved[-1] if saved else current)
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", rebuilt)
monkeypatch.setattr(
llmwiki_index,
"local_encoder_runtime_status",
lambda _app: {
"provider": "local" if saved[-1].local_wiki_encoder.mode == "local" else "openai",
"mode": saved[-1].local_wiki_encoder.mode,
},
)
local = await llmwiki_index.configure_local_encoder(
request,
llmwiki_index.LocalEncoderConfigRequest(model_path=str(model_dir)),
)
assert local["mode"] == "local"
assert saved[-1].local_wiki_encoder.mode == "local"
assert saved[-1].local_wiki_encoder.model_revision
configured = await llmwiki_index.use_configured_encoder(request)
assert configured["mode"] == "configured"
assert saved[-1].local_wiki_encoder.mode == "configured"
assert rebuilds == [True, True]
def test_both_shipped_offline_configs_enable_incremental_wiki_rescan() -> None:
offline_root = Path(__file__).resolve().parents[2]
for path in (offline_root / "config.yaml", offline_root / "backend" / "config.yaml"):
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
local_index = payload["llmwiki"]["local_wiki_index"]
assert local_index["enabled"] is True
assert local_index["auto_sync"] is True
@@ -497,6 +497,129 @@ async def test_weknora_client_resolves_default_embedding_model_inside_weknora()
assert created["id"] == "kb-created"
@pytest.mark.asyncio
async def test_gateway_lists_safe_embedding_models_and_uses_explicit_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.gateway.routers import llmwiki as llmwiki_router
create_calls: list[dict[str, object]] = []
class Client:
async def list_models(self) -> list[dict[str, object]]:
return [
{
"id": "embed-local",
"type": "Embedding",
"name": "Local BGE",
"provider": "local",
"status": "active",
"is_default": True,
"api_key": "must-not-leak",
"config": {"secret": "must-not-leak"},
},
{
"id": "embed-openai",
"type": "embedding",
"display_name": "OpenAI text-embedding",
"source": "remote",
"parameters": {
"model": "text-embedding-3-large",
"provider": "openai",
"api_key": "must-not-leak",
},
"status": "active",
},
{
"id": "embed-disabled",
"type": "Embedding",
"name": "Disabled",
"status": "inactive",
},
{"id": "chat-one", "type": "LLM", "name": "chat"},
]
async def create_knowledge_base(self, **kwargs: object) -> dict[str, object]:
create_calls.append(kwargs)
return {
"id": "remote-openai",
"name": kwargs["name"],
"description": kwargs["description"],
"type": kwargs["kb_type"],
"embedding_model_id": kwargs["embedding_model_id"],
}
async def actor(_: object) -> tuple[str, bool]:
return "user-a", False
store = MemoryLlmWikiStore()
client = Client()
monkeypatch.setattr(llmwiki_router, "_actor", actor)
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: client)
monkeypatch.setattr(llmwiki_router, "_store", lambda _: store)
monkeypatch.setattr(llmwiki_router, "_schedule_local_wiki_sync", lambda *_: None)
listed = await llmwiki_router.list_embedding_models(object())
assert listed["default_model_id"] == "embed-local"
assert [item["id"] for item in listed["models"]] == [
"embed-local",
"embed-openai",
"embed-disabled",
]
assert listed["models"][1]["provider"] == "openai"
assert listed["models"][1]["model_name"] == "text-embedding-3-large"
assert listed["models"][2]["available"] is False
assert "api_key" not in listed["models"][0]
assert "config" not in listed["models"][0]
assert "parameters" not in listed["models"][1]
created = await llmwiki_router.create_knowledge_base(
object(),
llmwiki_router.KnowledgeBaseCreate(
name="OpenAI docs",
embedding_model_id="embed-openai",
),
)
assert create_calls[0]["embedding_model_id"] == "embed-openai"
assert created["embedding_model_id"] == "embed-openai"
for rejected_model_id in ("embed-disabled", "chat-one", "missing"):
with pytest.raises(HTTPException) as exc_info:
await llmwiki_router.create_knowledge_base(
object(),
llmwiki_router.KnowledgeBaseCreate(
name="Rejected docs",
embedding_model_id=rejected_model_id,
),
)
assert exc_info.value.status_code == 422
assert len(create_calls) == 1
@pytest.mark.asyncio
async def test_gateway_embedding_model_list_allows_empty_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.gateway.routers import llmwiki as llmwiki_router
class Client:
async def list_models(self) -> list[dict[str, object]]:
return [{"id": "chat-only", "type": "LLM"}]
async def actor(_: object) -> tuple[str, bool]:
return "user-a", False
monkeypatch.setattr(llmwiki_router, "_actor", actor)
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
assert await llmwiki_router.list_embedding_models(object()) == {
"models": [],
"default_model_id": None,
}
@pytest.mark.asyncio
async def test_weknora_client_creates_wiki_with_weknora_owned_models() -> None:
model_reads = 0
@@ -199,34 +199,101 @@ async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> Non
@pytest.mark.asyncio
async def test_auto_sync_scheduler_scans_immediately_then_waits(monkeypatch) -> None:
calls: list[str] = []
async def test_auto_sync_scheduler_scans_immediately_then_only_when_due(monkeypatch) -> None:
now = 0.0
refreshes: list[float] = []
syncs: list[float] = []
sleeps: list[float] = []
config = SimpleNamespace(
enabled=True,
auto_sync=True,
sync_interval_seconds=25,
)
async def fake_refresh(_app):
refreshes.append(now)
return config
async def fake_sleep(seconds: float) -> None:
calls.append(f"sleep:{seconds}")
raise asyncio.CancelledError
async def fake_sync_once(app) -> None:
calls.append("sync")
if calls.count("sync") > 1:
nonlocal now
sleeps.append(seconds)
if len(syncs) >= 2:
raise asyncio.CancelledError
now += seconds
async def fake_sync_once(_app, *, config=None) -> None:
syncs.append(now)
monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now)
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
monkeypatch.setattr(
llmwiki_index_scheduler,
"_refresh_local_wiki_config",
fake_refresh,
)
app = SimpleNamespace(
state=SimpleNamespace(
config=SimpleNamespace(
llmwiki=SimpleNamespace(
local_wiki_index=SimpleNamespace(sync_interval_seconds=120),
)
)
llmwiki_sync_service=object(),
llmwiki_store=object(),
)
)
with pytest.raises(asyncio.CancelledError):
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
assert calls == ["sync", "sleep:120"]
assert refreshes == [0.0, 10.0, 20.0, 30.0]
assert syncs == [0.0, 30.0]
assert sleeps == [10.0, 10.0, 10.0, 10.0]
@pytest.mark.asyncio
async def test_auto_sync_scheduler_refreshes_provider_between_syncs(monkeypatch) -> None:
now = 0.0
seen_providers: list[str] = []
syncs: list[str] = []
providers = iter(("openai", "local", "local"))
async def fake_refresh(_app):
provider = next(providers)
seen_providers.append(provider)
_app.state.llmwiki_runtime_signature = provider
return SimpleNamespace(
enabled=True,
auto_sync=True,
sync_interval_seconds=120,
embedding=SimpleNamespace(provider=provider),
)
async def fake_sync_once(_app, *, config=None) -> None:
syncs.append(config.embedding.provider)
async def fake_sleep(seconds: float) -> None:
nonlocal now
now += seconds
if len(seen_providers) >= 3:
raise asyncio.CancelledError
monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now)
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
monkeypatch.setattr(
llmwiki_index_scheduler,
"_refresh_local_wiki_config",
fake_refresh,
)
app = SimpleNamespace(
state=SimpleNamespace(
llmwiki_sync_service=object(),
llmwiki_store=object(),
)
)
with pytest.raises(asyncio.CancelledError):
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
assert seen_providers == ["openai", "local", "local"]
assert syncs == ["openai", "local"]
def test_local_wiki_index_defaults_to_automatic_sync() -> None:
@@ -239,3 +239,67 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup
)
assert chat_model.extra_body is None
def _extra_body(chat_model) -> dict:
body = chat_model.extra_body or {}
return body if isinstance(body, dict) else dict(body)
def test_qwen38_flash_mode_disables_default_xhigh_thinking() -> None:
"""Qwen3.8 defaults to thinking=xhigh; flash must send enable_thinking=false."""
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="Qwen/Qwen3.8-27B",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=False,
)
]
)
chat_model = create_chat_model("qwen3.8-27b", thinking_enabled=False, app_config=config)
extra_body = _extra_body(chat_model)
assert extra_body.get("enable_thinking") is False
assert extra_body.get("chat_template_kwargs", {}).get("enable_thinking") is False
def test_qwen38_thinking_mode_sends_medium_reasoning_effort() -> None:
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="qwen3.8-27b",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=True,
)
]
)
chat_model = create_chat_model(
"qwen3.8-27b",
thinking_enabled=True,
reasoning_effort="medium",
app_config=config,
)
extra_body = _extra_body(chat_model)
kwargs = extra_body.get("chat_template_kwargs") or {}
assert extra_body.get("enable_thinking") is True
assert kwargs.get("enable_thinking") is True
assert kwargs.get("reasoning_effort") == "medium"
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
from deerflow.models.patched_openai import _extract_reasoning_content
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理"
assert _extract_reasoning_content({"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"
@@ -1,8 +1,10 @@
"""Regression coverage for provider-safe transient context compression."""
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, _ensure_latest_human_message
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
@@ -28,3 +30,37 @@ def test_compression_does_not_duplicate_human_turn_already_in_tail():
result = _ensure_latest_human_message([user], compressed)
assert result == compressed
def test_compression_uses_single_model_request_system_slot_for_generic_adapters():
model = FakeListChatModel(responses=["condensed history"])
middleware = DeerFlowSummarizationMiddleware(
model,
trigger=("messages", 3),
keep=("messages", 1),
)
request = ModelRequest(
model=model,
system_message=SystemMessage(content="agent prompt"),
messages=[
HumanMessage(content="first question", id="human-1"),
AIMessage(content="first answer", id="ai-1"),
HumanMessage(content="continue", id="human-2"),
],
)
captured: list[ModelRequest] = []
def handler(model_request: ModelRequest) -> ModelResponse:
captured.append(model_request)
return ModelResponse(result=[AIMessage(content="ok")])
middleware.wrap_model_call(request, handler)
assert len(captured) == 1
normalized = captured[0]
assert normalized.system_message is not None
assert "agent prompt" in normalized.system_message.text
assert "condensed history" in normalized.system_message.text
assert all(not isinstance(message, SystemMessage) for message in normalized.messages)
assert [message.type for message in normalized.messages] == ["human"]
assert normalized.messages[0].content == "continue"
@@ -375,8 +375,10 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
class Embedding:
def __init__(self):
self.model_path = ""
self.reset_calls = 0
async def reset_local_encoder(self, *, model_path=None):
self.reset_calls += 1
if model_path is not None:
self.model_path = model_path
@@ -395,6 +397,17 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
monkeypatch.setattr(llmwiki_index, "load_system_settings", SystemSettings)
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
embedding = Embedding()
async def rebuild(_app, *, force=False):
assert force is True
embedding.model_path = saved[-1].local_wiki_encoder.model_path
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", rebuild)
monkeypatch.setattr(
llmwiki_index,
"local_encoder_runtime_status",
lambda _app: embedding.local_encoder_status(),
)
app = FastAPI()
app.state.config = SimpleNamespace(
llmwiki=SimpleNamespace(
@@ -414,11 +427,15 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
paths=["onnx/model.onnx", "config.json"],
)
active = tmp_path / "models" / "bge-m3"
revision = saved[-1].local_wiki_encoder.model_revision
active = tmp_path / "models" / "bge-m3-revisions" / revision
assert (active / "onnx" / "model.onnx").read_bytes() == b"onnx"
assert result["uploaded_files"] == 2
assert saved[-1].local_wiki_encoder.model_path == str(active.resolve())
assert saved[-1].local_wiki_encoder.mode == "local"
assert revision
assert embedding.model_path == str(active.resolve())
assert embedding.reset_calls == 0
def test_encoder_upload_rejects_parent_traversal():
+28 -1
View File
@@ -116,6 +116,33 @@ vision:
# max_tokens: 2048
knowledge_ingest:
url: "https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add"
# WeKnora connection can be supplied from the administrator settings page.
# Keep release credentials out of this portable offline template; a non-empty
# runtime URL activates WeKnora and hot-rebuilds the Wiki sync service.
llmwiki:
weknora:
api_base_url: ""
web_base_url: ""
admin_email: ""
admin_password: ""
local_wiki_index:
enabled: true
frontend_base_url: "http://127.0.0.1:5174"
# WeKnora Wiki generation is asynchronous, so the immediate scan alone can
# miss newly-created pages. Keep the incremental rescan enabled.
auto_sync: true
sync_interval_seconds: 30
database_batch_size: 50
embedding:
provider: local
model: bge-embedding-m3
dimensions: 1024
batch_size: 2
local_threads: 2
local_process_isolation: true
local_model_path: ""
base_url: ""
api_key: ""
# 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
@@ -264,7 +291,7 @@ ai_writing:
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个
+2 -2
View File
@@ -58,8 +58,8 @@ nohup bash -c '
--reload \
--reload-dir "app" \
--reload-dir "packages" \
--reload-exclude "$0/.deer-flow" \
--reload-exclude "$0/.runtime"
--reload-exclude ".deer-flow" \
--reload-exclude ".runtime"
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
>"${LOG_FILE}" 2>&1 &