Compare commits

...
6 Commits
Author SHA1 Message Date
董宏凯 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
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

See merge request knowledge-base-bj/deerflow-code!39
2026-09-07 09:53:09 +00:00
吉鑫升 3ca5528640 feat(knowledge): support standalone file ingestion and configurable wiki entry
Use neutral knowledge-service labels and sanitize user-facing task errors.
2026-09-07 17:07:27 +08:00
董宏凯 f9b6d7e322 Merge branch 'master-nj' into 'master'
feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底

See merge request knowledge-base-bj/deerflow-code!38
2026-09-07 06:58:42 +00:00
52 changed files with 2210 additions and 556 deletions

No files matched your search

+11 -9
View File
@@ -88,18 +88,20 @@ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. # 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_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.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 !offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned. # Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py !offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned. # Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py !offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py !offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned. # Keep fixed-question exact-match and streaming regression coverage versioned.
@@ -176,10 +178,10 @@ docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference) # uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py !offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed) # Local-only scratch directory (not committed)
0622/ 0622/
+3
View File
@@ -59,6 +59,9 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。 # 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A # VITE_COMPACT_MENU_TYPE=A
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# VITE_GITHUB_OAUTH_TOKEN= # VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- # --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
+5
View File
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。 // GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "", VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。 // 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。 // 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true", VITE_SENTIMENT_AGENT_VISIBLE: "true",
@@ -7,6 +7,7 @@ import {
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
FolderTreeIcon, FolderTreeIcon,
GaugeIcon,
GraduationCapIcon, GraduationCapIcon,
LightbulbIcon, LightbulbIcon,
PaperclipIcon, PaperclipIcon,
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types"; import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -93,14 +95,14 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences"; import { useUserPreferences } from "@/core/user-preferences";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -223,16 +225,20 @@ export function InputBox({
context.execution_profile === "single_round_rag"; context.execution_profile === "single_round_rag";
const multiRoundRagActive = const multiRoundRagActive =
context.execution_profile === "multi_round_rag"; context.execution_profile === "multi_round_rag";
const turboModeVisible = isTurboModeVisible();
const directChatActive =
turboModeVisible && context.execution_profile === "direct";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive; const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const bypassAgentActive = frontendRagActive || directChatActive;
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models; return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]); }, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -253,11 +259,37 @@ export function InputBox({
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
useEffect(() => { useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option. // Frontend-only execution profiles are not model options.
// Do not let the normal model fallback clear it on every render. // Do not let the normal model fallback clear them on every render.
if (!turboModeVisible && context.execution_profile === "direct") {
onContextChange?.({
...context,
execution_profile: undefined,
});
return;
}
if (multiRoundRagActive) { if (multiRoundRagActive) {
return; return;
} }
if (directChatActive) {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
if (
context.model_name === fallbackModel.name &&
context.execution_profile === "direct"
) {
return;
}
onContextChange?.({
...context,
model_name: fallbackModel.name,
execution_profile: "direct",
});
return;
}
if (modelOptions.length === 0) { if (modelOptions.length === 0) {
return; return;
} }
@@ -281,7 +313,7 @@ export function InputBox({
mode: nextMode, mode: nextMode,
execution_profile: fallbackModel.execution_profile, execution_profile: fallbackModel.execution_profile,
}); });
}, [context, modelOptions, multiRoundRagActive, onContextChange]); }, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
const selectedModel = useMemo(() => { const selectedModel = useMemo(() => {
if (modelOptions.length === 0) { if (modelOptions.length === 0) {
@@ -314,7 +346,10 @@ export function InputBox({
model_name, model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false), mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort, reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile, execution_profile:
context.execution_profile === "direct"
? "direct"
: model.execution_profile,
}); });
setModelDialogOpen(false); setModelDialogOpen(false);
}, },
@@ -346,6 +381,16 @@ export function InputBox({
[onContextChange, context, models, supportThinking], [onContextChange, context, models, supportThinking],
); );
const handleDirectChatSelect = useCallback(() => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
execution_profile: "direct",
});
}, [context, models, onContextChange]);
const handleSingleRoundRagSelect = useCallback(() => { const handleSingleRoundRagSelect = useCallback(() => {
const ragModel = const ragModel =
extraModels.find((model) => model.name === context.model_name) ?? extraModels.find((model) => model.name === context.model_name) ??
@@ -749,7 +794,7 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
{!frontendRagActive && ( {!bypassAgentActive && (
<AddAttachmentsButton <AddAttachmentsButton
className={cn( className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
@@ -758,7 +803,7 @@ export function InputBox({
)} )}
/> />
)} )}
{!frontendRagActive && !hideContextTools && <Tooltip {!bypassAgentActive && !hideContextTools && <Tooltip
content={ content={
context.writing_mode context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料" ? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -784,7 +829,7 @@ export function InputBox({
<span className="text-xs font-normal">写作</span> <span className="text-xs font-normal">写作</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && <Tooltip {!bypassAgentActive && <Tooltip
content={ content={
artifactDirectoryModeEnabled artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录" ? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
@@ -813,7 +858,7 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip {!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={ content={
memoryInjectionEnabled memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆" ? "记忆注入已开启:对话前会注入历史记忆"
@@ -839,9 +884,9 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启" : "参考文献模式已关闭:点击开启"
} }
@@ -869,7 +914,7 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span> <span className="text-xs font-normal">参考文献</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && <Tooltip {!bypassAgentActive && <Tooltip
content={ content={
contextCompressionEnabled contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token" ? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -909,7 +954,9 @@ export function InputBox({
: "flash" : "flash"
} }
tooltipContent={ tooltipContent={
singleRoundRagActive directChatActive
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
: singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料" ? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive : multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料" ? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
@@ -921,7 +968,9 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact && compact &&
(frontendRagActive (directChatActive
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
: frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200" ? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking" : context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200" ? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -933,7 +982,9 @@ export function InputBox({
)} )}
> >
<div> <div>
{frontendRagActive ? ( {directChatActive ? (
<GaugeIcon className="size-3" />
) : frontendRagActive ? (
<BookMarkedIcon className="size-3" /> <BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? ( ) : context.mode === "flash" ? (
<ZapIcon className="size-3" /> <ZapIcon className="size-3" />
@@ -948,10 +999,14 @@ export function InputBox({
<div <div
className={cn( className={cn(
"text-xs font-normal", "text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "", context.mode === "ultra" && !directChatActive && !frontendRagActive
? "golden-text"
: "",
)} )}
> >
{frontendRagActive {directChatActive
? t.inputBox.turboMode
: frontendRagActive
? multiRoundRagActive ? multiRoundRagActive
? "多轮 RAG" ? "多轮 RAG"
: "单轮 RAG" : "单轮 RAG"
@@ -969,9 +1024,39 @@ export function InputBox({
{t.inputBox.mode} {t.inputBox.mode}
</DropdownMenuLabel> </DropdownMenuLabel>
<PromptInputActionMenu> <PromptInputActionMenu>
{turboModeVisible && (
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "flash" directChatActive
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleDirectChatSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GaugeIcon
className={cn(
"mr-2 size-4",
directChatActive && "text-sky-700 dark:text-sky-200",
)}
/>
{t.inputBox.turboMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.turboModeDescription}
</div>
</div>
{directChatActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "flash"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -982,7 +1067,7 @@ export function InputBox({
<ZapIcon <ZapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "flash" && !bypassAgentActive && context.mode === "flash" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -992,7 +1077,7 @@ export function InputBox({
{t.inputBox.flashModeDescription} {t.inputBox.flashModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "flash" ? ( {!bypassAgentActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1001,7 +1086,7 @@ export function InputBox({
{supportThinking && ( {supportThinking && (
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "thinking" !bypassAgentActive && context.mode === "thinking"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1012,7 +1097,7 @@ export function InputBox({
<LightbulbIcon <LightbulbIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "thinking" && !bypassAgentActive && context.mode === "thinking" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -1022,7 +1107,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription} {t.inputBox.reasoningModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "thinking" ? ( {!bypassAgentActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1031,7 +1116,7 @@ export function InputBox({
)} )}
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "pro" !bypassAgentActive && context.mode === "pro"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1042,7 +1127,7 @@ export function InputBox({
<GraduationCapIcon <GraduationCapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && context.mode === "pro" && !bypassAgentActive && context.mode === "pro" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -1052,7 +1137,7 @@ export function InputBox({
{t.inputBox.proModeDescription} {t.inputBox.proModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "pro" ? ( {!bypassAgentActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1060,7 +1145,7 @@ export function InputBox({
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
!frontendRagActive && context.mode === "ultra" !bypassAgentActive && context.mode === "ultra"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -1071,14 +1156,14 @@ export function InputBox({
<RocketIcon <RocketIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
!frontendRagActive && !bypassAgentActive &&
context.mode === "ultra" && context.mode === "ultra" &&
"text-[#dabb5e]", "text-[#dabb5e]",
)} )}
/> />
<div <div
className={cn( className={cn(
!frontendRagActive && !bypassAgentActive &&
context.mode === "ultra" && context.mode === "ultra" &&
"golden-text", "golden-text",
)} )}
@@ -1090,7 +1175,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription} {t.inputBox.ultraModeDescription}
</div> </div>
</div> </div>
{!frontendRagActive && context.mode === "ultra" ? ( {!bypassAgentActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -1161,7 +1246,7 @@ export function InputBox({
</DropdownMenuGroup> </DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && ( {!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}> <PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal"> <div className="text-xs font-normal">
@@ -1279,7 +1364,7 @@ export function InputBox({
)} )}
</PromptInputTools> </PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}> <PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && ( {!bypassAgentActive && agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange} onAgentChange={agentSelector.onAgentChange}
@@ -1287,8 +1372,8 @@ export function InputBox({
compact={compact} compact={compact}
/> />
)} )}
{!frontendRagActive && knowledgeSelector} {!bypassAgentActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && ( {!bypassAgentActive && promptPrefix && (
<Tooltip <Tooltip
content={ content={
promptPrefix.on promptPrefix.on
@@ -6,13 +6,22 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import { import {
loadKnowledgeBaseProductVersion, loadKnowledgeBaseProductVersion,
loadOrdinaryKnowledgeLanding,
saveOrdinaryKnowledgeLanding,
type OrdinaryKnowledgeLanding,
type KnowledgeBaseProductVersion, type KnowledgeBaseProductVersion,
updateKnowledgeBaseProductVersion, updateKnowledgeBaseProductVersion,
} from "@/core/system-settings/api"; } from "@/core/system-settings/api";
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const; export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
export function useOrdinaryKnowledgeLanding() {
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
}
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal"; export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`; export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
const navigate = useNavigate(); const navigate = useNavigate();
const client = useQueryClient(); const client = useQueryClient();
const version = useKnowledgeBaseProductVersion(); const version = useKnowledgeBaseProductVersion();
const landing = useOrdinaryKnowledgeLanding();
const saveLanding = useMutation({
mutationFn: saveOrdinaryKnowledgeLanding,
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
onError: (error) => toast.error(error.message),
});
const runtime = useLlmWikiRuntime();
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const save = useMutation({ const save = useMutation({
mutationFn: updateKnowledgeBaseProductVersion, mutationFn: updateKnowledgeBaseProductVersion,
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
const assistant = location.pathname.includes("/knowledge/assistant"); const assistant = location.pathname.includes("/knowledge/assistant");
return ( return (
<div className="flex items-center gap-2 border-b bg-card px-6 py-2"> <div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}> <Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
<BookOpen />普通知识库 <BookOpen />普通知识库
</Button> </Button>
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}> <Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
<Database />助手知识库 <Database />助手知识库
</Button> </Button>
{isAdmin && ( {isAdmin && (
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground"> <div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
普通知识库默认页面
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
</Select>
默认产品版本 默认产品版本
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}> <Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
/> />
<Database className="size-4 text-muted-foreground" /> <Database className="size-4 text-muted-foreground" />
<span className="text-sm">{target.target_name}</span> <span className="text-sm">{target.target_name}</span>
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge> <Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
</label> </label>
); );
})} })}
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />} {(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && ( {!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
<p className="p-3 text-xs text-destructive"> <p className="p-3 text-xs text-destructive">
读取 WeKnora 普通知识库失败: 读取普通知识库失败:
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)} {knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
</p> </p>
)} )}
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && ( {!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
<p className="p-3 text-xs text-muted-foreground"> <p className="p-3 text-xs text-muted-foreground">
{runtime.data?.provider !== "weknora" {runtime.data?.provider !== "weknora"
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。" ? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"} : "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
</p> </p>
)} )}
+30 -27
View File
@@ -96,6 +96,9 @@ export const enUS: Translations = {
mode: "Mode", mode: "Mode",
flashMode: "Flash", flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate", flashModeDescription: "Fast and efficient, but may not be accurate",
turboMode: "Turbo",
turboModeDescription:
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
reasoningMode: "Reasoning", reasoningMode: "Reasoning",
reasoningModeDescription: reasoningModeDescription:
"Reasoning before action, balance between time and accuracy", "Reasoning before action, balance between time and accuracy",
@@ -448,17 +451,17 @@ export const enUS: Translations = {
system: "System", system: "System",
light: "Light", light: "Light",
dark: "Dark", dark: "Dark",
lightblue: "Light Blue", lightblue: "Light Blue",
lightgreen: "Green", lightgreen: "Green",
iceblue: "Glacier Blue", iceblue: "Glacier Blue",
lightyellow: "Pale Yellow", lightyellow: "Pale Yellow",
systemDescription: "Match the operating system preference automatically.", systemDescription: "Match the operating system preference automatically.",
lightDescription: "Bright palette with higher contrast for daytime.", lightDescription: "Bright palette with higher contrast for daytime.",
darkDescription: "Dim palette that reduces glare for focus.", darkDescription: "Dim palette that reduces glare for focus.",
lightblueDescription: "A crisp light palette built around blue.", lightblueDescription: "A crisp light palette built around blue.",
lightgreenDescription: "A crisp light palette built around fresh green tones.", lightgreenDescription: "A crisp light palette built around fresh green tones.",
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.", iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.", lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.",
languageTitle: "Language", languageTitle: "Language",
languageDescription: "Switch between languages.", languageDescription: "Switch between languages.",
}, },
@@ -545,13 +548,13 @@ export const enUS: Translations = {
filesSaveAction: "Save", filesSaveAction: "Save",
filesCancelEditAction: "Cancel", filesCancelEditAction: "Cancel",
filesDownloadAction: "Download", filesDownloadAction: "Download",
filesCopyPathAction: "Copy path", filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?", filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved", filesSaveSuccess: "File saved",
filesDeleteAction: "Delete", filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.", filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted", filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.", filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details", viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
@@ -591,26 +594,26 @@ export const enUS: Translations = {
chooseFile: "Choose file", chooseFile: "Choose file",
validating: "Validating archive…", validating: "Validating archive…",
installing: "Installing…", installing: "Installing…",
replace: "Upload again and replace", replace: "Upload again and replace",
install: "Install", install: "Install",
cancel: "Cancel", cancel: "Cancel",
nameLabel: "Name", nameLabel: "Name",
descriptionLabel: "Description", descriptionLabel: "Description",
licenseLabel: "License", licenseLabel: "License",
toolsLabel: "Allowed tools", toolsLabel: "Allowed tools",
warningsLabel: "Warnings", warningsLabel: "Warnings",
conflictOwnedByMe: conflictOwnedByMe:
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).", "You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
conflictOwnedByMeWrongTarget: conflictOwnedByMeWrongTarget:
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.", "The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
conflictOwnedByOther: conflictOwnedByOther:
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.", "Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
conflictCustom: conflictCustom:
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.", "A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
conflictPublic: conflictPublic:
"A built-in public skill named '{name}' already exists and cannot be overwritten.", "A built-in public skill named '{name}' already exists and cannot be overwritten.",
conflictPublicAdminHint: conflictPublicAdminHint:
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.", "A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
installSuccess: "Skill '{name}' installed.", installSuccess: "Skill '{name}' installed.",
installSuccessReplaced: "Skill '{name}' replaced.", installSuccessReplaced: "Skill '{name}' replaced.",
rejectInvalidExt: "Only .zip or .skill files are accepted.", rejectInvalidExt: "Only .zip or .skill files are accepted.",
+23 -21
View File
@@ -91,6 +91,8 @@ export interface Translations {
mode: string; mode: string;
flashMode: string; flashMode: string;
flashModeDescription: string; flashModeDescription: string;
turboMode: string;
turboModeDescription: string;
reasoningMode: string; reasoningMode: string;
reasoningModeDescription: string; reasoningModeDescription: string;
proMode: string; proMode: string;
@@ -383,17 +385,17 @@ export interface Translations {
system: string; system: string;
light: string; light: string;
dark: string; dark: string;
lightblue: string; lightblue: string;
lightgreen: string; lightgreen: string;
iceblue: string; iceblue: string;
lightyellow: string; lightyellow: string;
systemDescription: string; systemDescription: string;
lightDescription: string; lightDescription: string;
darkDescription: string; darkDescription: string;
lightblueDescription: string; lightblueDescription: string;
lightgreenDescription: string; lightgreenDescription: string;
iceblueDescription: string; iceblueDescription: string;
lightyellowDescription: string; lightyellowDescription: string;
languageTitle: string; languageTitle: string;
languageDescription: string; languageDescription: string;
}; };
@@ -475,13 +477,13 @@ export interface Translations {
filesSaveAction: string; filesSaveAction: string;
filesCancelEditAction: string; filesCancelEditAction: string;
filesDownloadAction: string; filesDownloadAction: string;
filesCopyPathAction: string; filesCopyPathAction: string;
filesDiscardConfirm: string; filesDiscardConfirm: string;
filesSaveSuccess: string; filesSaveSuccess: string;
filesDeleteAction: string; filesDeleteAction: string;
filesDeleteConfirm: string; filesDeleteConfirm: string;
filesDeleteSuccess: string; filesDeleteSuccess: string;
filesReadOnlyHint: string; filesReadOnlyHint: string;
viewDetailAction: string; viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
@@ -525,12 +527,12 @@ export interface Translations {
nameLabel: string; nameLabel: string;
descriptionLabel: string; descriptionLabel: string;
licenseLabel: string; licenseLabel: string;
toolsLabel: string; toolsLabel: string;
warningsLabel: string; warningsLabel: string;
conflictOwnedByMe: string; conflictOwnedByMe: string;
conflictOwnedByMeWrongTarget: string; conflictOwnedByMeWrongTarget: string;
conflictOwnedByOther: string; conflictOwnedByOther: string;
conflictCustom: string; conflictCustom: string;
conflictPublic: string; conflictPublic: string;
conflictPublicAdminHint: string; conflictPublicAdminHint: string;
installSuccess: string; installSuccess: string;
+26 -24
View File
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
mode: "模式", mode: "模式",
flashMode: "快速", flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准", flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考", reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡", reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答", proMode: "专业问答",
@@ -429,17 +431,17 @@ export const zhCN: Translations = {
system: "系统", system: "系统",
light: "浅色", light: "浅色",
dark: "深色", dark: "深色",
lightblue: "浅蓝", lightblue: "浅蓝",
lightgreen: "绿色", lightgreen: "绿色",
iceblue: "冰川蓝", iceblue: "冰川蓝",
lightyellow: "浅黄色", lightyellow: "浅黄色",
systemDescription: "自动跟随系统主题。", systemDescription: "自动跟随系统主题。",
lightDescription: "更明亮的配色,适合日间使用。", lightDescription: "更明亮的配色,适合日间使用。",
darkDescription: "更暗的配色,减少眩光方便专注。", darkDescription: "更暗的配色,减少眩光方便专注。",
lightblueDescription: "以蓝色为主色的清爽浅色配色。", lightblueDescription: "以蓝色为主色的清爽浅色配色。",
lightgreenDescription: "采用清新绿色的浅色配色。", lightgreenDescription: "采用清新绿色的浅色配色。",
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。", iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。", lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。",
languageTitle: "语言", languageTitle: "语言",
languageDescription: "在不同语言之间切换。", languageDescription: "在不同语言之间切换。",
}, },
@@ -524,11 +526,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载", filesDownloadAction: "下载",
filesCopyPathAction: "复制路径", filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?", filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存", filesSaveSuccess: "文件已保存",
filesDeleteAction: "删除", filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。", filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除", filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。", filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情", viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
@@ -566,24 +568,24 @@ export const zhCN: Translations = {
chooseFile: "选择文件", chooseFile: "选择文件",
validating: "正在校验文件…", validating: "正在校验文件…",
installing: "安装中…", installing: "安装中…",
replace: "重复上传并覆盖", replace: "重复上传并覆盖",
install: "安装", install: "安装",
cancel: "取消", cancel: "取消",
nameLabel: "名称", nameLabel: "名称",
descriptionLabel: "描述", descriptionLabel: "描述",
licenseLabel: "许可证", licenseLabel: "许可证",
toolsLabel: "允许的工具", toolsLabel: "允许的工具",
warningsLabel: "警告", warningsLabel: "警告",
conflictOwnedByMe: conflictOwnedByMe:
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。", "您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
conflictOwnedByMeWrongTarget: conflictOwnedByMeWrongTarget:
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。", "同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
conflictOwnedByOther: conflictOwnedByOther:
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。", "技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
conflictCustom: conflictCustom:
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。", "已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`, conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`, conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
installSuccess: `技能 '{name}' 已安装。`, installSuccess: `技能 '{name}' 已安装。`,
installSuccessReplaced: `技能 '{name}' 已替换。`, installSuccessReplaced: `技能 '{name}' 已替换。`,
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`, rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
+13 -5
View File
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return null; return null;
} }
if ( if (message.additional_kwargs) {
message.additional_kwargs && const reasoningContent = message.additional_kwargs.reasoning_content;
"reasoning_content" in message.additional_kwargs if (typeof reasoningContent === "string") {
) { return reasoningContent;
return message.additional_kwargs.reasoning_content as string | null; }
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
const reasoning = message.additional_kwargs.reasoning;
if (typeof reasoning === "string") {
return reasoning;
}
} }
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
+5 -5
View File
@@ -4,11 +4,11 @@ export interface Model {
model: string; model: string;
display_name: string; display_name: string;
description?: string | null; description?: string | null;
supports_thinking?: boolean; supports_thinking?: boolean;
supports_reasoning_effort?: boolean; supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */ /** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag" | "multi_round_rag"; execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
} }
export interface TokenUsageSettings { export interface TokenUsageSettings {
enabled: boolean; enabled: boolean;
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
export type KnowledgeBaseProductVersion = "v1" | "v2"; export type KnowledgeBaseProductVersion = "v1" | "v2";
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
});
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
}
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> { export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`, `${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
+43 -42
View File
@@ -16,14 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config"; import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log"; import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "../settings"; } from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks"; import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -571,11 +571,11 @@ export function useThreadStream({
onToolStart, onToolStart,
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled = const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false; citationDisplay?.reference_mode_enabled ?? false;
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1507,21 +1507,21 @@ export function useThreadStream({
...context, ...context,
...extraContext, ...extraContext,
}; };
const selectedLlmWikiIds = Array.isArray( const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids, mergedContext.llmwiki_knowledge_base_ids,
) )
? mergedContext.llmwiki_knowledge_base_ids ? mergedContext.llmwiki_knowledge_base_ids
: []; : [];
const selectedAssistantKnowledgeIds = Array.isArray( const selectedAssistantKnowledgeIds = Array.isArray(
mergedContext.assistant_knowledge_base_ids, mergedContext.assistant_knowledge_base_ids,
) )
? mergedContext.assistant_knowledge_base_ids ? mergedContext.assistant_knowledge_base_ids
: []; : [];
const ragModeEnabled = const ragModeEnabled =
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) || (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0 || selectedLlmWikiIds.length > 0 ||
selectedAssistantKnowledgeIds.length > 0; selectedAssistantKnowledgeIds.length > 0;
if (ragModeEnabled) { if (ragModeEnabled) {
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
@@ -1579,6 +1579,7 @@ export function useThreadStream({
context: { context: {
...mergedContext, ...mergedContext,
thinking_enabled: mergedContext.mode !== "flash", thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode: is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra", mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra", subagent_enabled: mergedContext.mode === "ultra",
@@ -1639,18 +1640,18 @@ export function useThreadStream({
sendInFlightRef.current = false; sendInFlightRef.current = false;
} }
}, },
[ [
thread, thread,
_handleOnStart, _handleOnStart,
t.uploads.uploadingFiles, t.uploads.uploadingFiles,
context, context,
referenceModeEntryEnabled, referenceModeEntryEnabled,
localSettings.display.ragModeEnabled, localSettings.display.ragModeEnabled,
queryClient, queryClient,
runOptions, runOptions,
confirmOrRejoin, confirmOrRejoin,
], ],
); );
const handleThreadStop = useCallback(async () => { const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current; const tid = threadIdRef.current;
@@ -1699,7 +1700,7 @@ export function useThreadStream({
? undefined ? undefined
: thread.error; : thread.error;
const mergedThread = { const mergedThread = {
...thread, ...thread,
error: visibleThreadError, error: visibleThreadError,
values: mergedValues, values: mergedValues,
@@ -1709,7 +1710,7 @@ export function useThreadStream({
: mergedMessages, : mergedMessages,
isLoading: thread.isLoading || streamHoldOpen, isLoading: thread.isLoading || streamHoldOpen,
stop: handleThreadStop, stop: handleThreadStop,
} as typeof thread; } as typeof thread;
// 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。
// 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话
+18 -16
View File
@@ -2,18 +2,20 @@ import type { Message, Thread } from "@langchain/langgraph-sdk";
import type { Todo } from "../todos"; import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> { export interface AgentThreadState extends Record<string, unknown> {
title: string; title: string;
title_provisional?: boolean; title_provisional?: boolean;
messages: Message[]; messages: Message[];
artifacts: string[]; artifacts: string[];
todos?: Todo[]; todos?: Todo[];
} }
export interface AgentThreadContext extends Record<string, unknown> { export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string; thread_id: string;
model_name: string | undefined; model_name: string | undefined;
thinking_enabled: boolean; thinking_enabled: boolean;
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
thinking_force_disabled?: boolean;
is_plan_mode: boolean; is_plan_mode: boolean;
subagent_enabled: boolean; subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
@@ -23,15 +25,15 @@ export interface AgentThreadContext extends Record<string, unknown> {
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */ /** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean; knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */ /** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[]; llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */ /** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[]; assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */ /** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag"; execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string; agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
} }
@@ -0,0 +1,312 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeFlag } from "@/env";
import { createClientId } from "@/lib/client-id";
import { textOfMessage } from "./utils";
const STORAGE_PREFIX = "workspace.direct-chat.";
const MAX_HISTORY_MESSAGES = 20;
export function isDirectChatProfile(context: unknown): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile === "direct"
);
}
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
export function isTurboModeVisible(): boolean {
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException
? error.name === "AbortError"
: error instanceof Error && error.name === "AbortError";
}
function historyFromMessages(messages: Message[]): Array<{
role: "user" | "assistant";
content: string;
}> {
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
const content = textOfMessage(message)?.trim();
if (!content) continue;
history.push({
role: message.type === "human" ? "user" : "assistant",
content,
});
}
return history.slice(-MAX_HISTORY_MESSAGES);
}
function parseSseBlocks(buffer: string): {
events: Array<{ event?: string; data: string }>;
rest: string;
} {
const parts = buffer.split("\n\n");
const rest = parts.pop() ?? "";
const events: Array<{ event?: string; data: string }> = [];
for (const part of parts) {
let event: string | undefined;
const dataLines: string[] = [];
for (const line of part.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join("\n") });
}
}
return { events, rest };
}
async function readErrorDetail(response: Response): Promise<string> {
try {
const payload: unknown = await response.json();
if (
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
) {
return (payload as { detail: string }).detail;
}
} catch {
// Fall through to status text.
}
return `极速问答失败(${response.status})`;
}
/**
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
* It never submits a LangGraph run or touches the lead-agent stack.
*/
export function useDirectChatMessages(threadId: string | undefined) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId ?? ""),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId ?? "");
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName?: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createClientId("direct-human");
const assistantId = createClientId("direct-ai");
const clientTs = new Date().toISOString();
const history = historyFromMessages(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, direct_chat: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { direct_chat: true },
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
initialAssistant,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
let answer = "";
const refreshAssistant = (content: string) => {
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
};
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/direct-chat/stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model_name: modelName,
messages: [...history, { role: "user", content: question }],
}),
},
);
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
if (!response.body) {
throw new Error("极速问答未返回数据流。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseBlocks(buffer);
buffer = parsed.rest;
for (const block of parsed.events) {
let payload: unknown = null;
try {
payload = JSON.parse(block.data);
} catch {
continue;
}
if (block.event === "error") {
const detail =
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
? (payload as { detail: string }).detail
: "极速问答失败。";
throw new Error(detail);
}
if (
block.event === "delta" &&
payload &&
typeof payload === "object" &&
"text" in payload &&
typeof (payload as { text: unknown }).text === "string"
) {
answer += (payload as { text: string }).text;
refreshAssistant(answer);
}
if (
block.event === "result" &&
payload &&
typeof payload === "object" &&
"answer" in payload &&
typeof (payload as { answer: unknown }).answer === "string"
) {
answer = (payload as { answer: string }).answer;
refreshAssistant(answer);
}
}
}
if (!answer) {
throw new Error("模型未返回任何文本。");
}
} catch (caught) {
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "极速问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answer || message,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
@@ -0,0 +1,4 @@
/** Neutral product terminology for system messages; never apply to knowledge content or API identifiers. */
export function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
+87 -8
View File
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -560,9 +562,26 @@ export default function AgentChatPage({
}, },
}); });
const directChat = useDirectChatMessages(threadId);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread } = useResearchWritingFeed(threadId, thread); const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (directChat.messages.length === 0 && !directChat.isLoading) {
return base;
}
const messages = [...base.messages, ...directChat.messages];
return {
...base,
values: { ...base.values, messages },
messages,
isLoading: base.isLoading || directChat.isLoading,
} as typeof base;
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
// ── agentfx「入库」协助卡 ────────────────────────────────────────────────── // ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且 // 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -743,6 +762,62 @@ export default function AgentChatPage({
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => { (message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
saveLastAgentId(agent_id);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient().threads.create({
metadata: {
agent_id,
...(Object.keys(conversationMetadata).length > 0
? conversationMetadata
: {}),
},
});
targetThreadId = created.thread_id;
knowledgeSelectionThread.current = targetThreadId;
activeThreadIdRef.current = targetThreadId;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient().threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
setHasStartedConversation(true); setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next // Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay). // time (covers both direct agent chats and the ChatPage handoff replay).
@@ -767,7 +842,7 @@ export default function AgentChatPage({
...pageReportThemeContext, ...pageReportThemeContext,
}); });
}, },
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode], [sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
); );
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -852,8 +927,12 @@ export default function AgentChatPage({
); );
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop(); await thread.stop();
}, [thread]); }, [directChat, thread]);
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。 // 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -867,7 +946,7 @@ export default function AgentChatPage({
const status = thread.error const status = thread.error
? "error" ? "error"
: thread.isLoading : thread.isLoading || directChat.isLoading
? "streaming" ? "streaming"
: "ready"; : "ready";
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
autoFocus autoFocus
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
pendingKnowledgeSelection.current === null && pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null pendingAssistantKnowledgeSelection.current === null
} }
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
label="知识空间" label="知识空间"
/> />
) )
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
assistantId={agent_id} assistantId={agent_id}
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds} assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection} onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection} onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading} disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
readOnly readOnly
label="知识空间" label="知识空间"
/> />
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { import {
ArrowLeft, ArrowLeft,
BookOpenText, BookOpenText,
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
ReturnType<typeof searchAssistantWiki> ReturnType<typeof searchAssistantWiki>
> | null>(null); > | null>(null);
const upload = useRef<HTMLInputElement>(null); const upload = useRef<HTMLInputElement>(null);
const documentUpload = useRef<HTMLInputElement>(null);
const base = bases.data?.find((b) => b.id === baseId); const base = bases.data?.find((b) => b.id === baseId);
const libraryPages = useMemo<LibraryWikiPage[]>( const libraryPages = useMemo<LibraryWikiPage[]>(
() => () =>
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
<div> <div>
<h1 className="text-2xl font-semibold">助手知识库</h1> <h1 className="text-2xl font-semibold">助手知识库</h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
浏览分类 选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
</p> </p>
</div> </div>
{isAdmin && ( {isAdmin && (
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
删除 删除
</Button> </Button>
)} )}
{isAdmin && (
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
上传文件生成 Wiki
</Button>
)}
{isAdmin && ( {isAdmin && (
<Button <Button
variant="outline" variant="outline"
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
onClick={() => upload.current?.click()} onClick={() => upload.current?.click()}
> >
<Upload className="mr-2 size-4" /> <Upload className="mr-2 size-4" />
导入数据包 导入 普通知识库数据包
</Button> </Button>
)} )}
</div> </div>
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
onChange={async (event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
setTab("transfer");
for (const file of files) {
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
try {
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
}
}} />
<input <input
ref={upload} ref={upload}
type="file" type="file"
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
上次同步:{new Date(s.updated_at).toLocaleString()} 上次同步:{new Date(s.updated_at).toLocaleString()}
</p> </p>
{isAdmin && ( {s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
{isAdmin && s.source_type === "weknora" && (
<Button <Button
className="mt-4" className="mt-4"
size="sm" size="sm"
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
> >
<WikiPackageTransfers id={baseId} assistant /> <WikiPackageTransfers id={baseId} assistant />
<h2 className="text-base font-semibold">导入与同步记录</h2> <h2 className="text-base font-semibold">导入与同步记录</h2>
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
{jobs.data?.map((j) => ( {jobs.data?.map((j) => (
<div key={j.id} className="rounded-xl border p-4"> <div key={j.id} className="rounded-xl border p-4">
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="outline"> <Badge variant="outline">
{{ {{
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
{new Date(j.created_at).toLocaleString()} {new Date(j.created_at).toLocaleString()}
</span> </span>
</div> </div>
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
<p className="mt-3 text-sm"> <p className="mt-3 text-sm">
{j.counts?.wiki_pages || 0} Wiki ·{" "} {j.counts?.wiki_pages || 0} Wiki ·{" "}
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "} {j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
</p> </p>
{j.last_error_message && ( {j.last_error_message && (
<p className="mt-2 text-sm text-destructive"> <p className="mt-2 text-sm text-destructive">
{j.last_error_message} {userFacingKnowledgeMessage(j.last_error_message)}
</p> </p>
)} )}
{j.trigger === "local_file_upload" && j.status !== "completed" && (
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
catch (error) { toast.error((error as Error).message); }
}}>重试 / 恢复处理</Button>
)}
</div> </div>
))} ))}
</TabsContent> </TabsContent>
+69 -5
View File
@@ -69,6 +69,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag"; import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag"; import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
@@ -348,9 +349,12 @@ export default function ChatPage() {
}); });
const singleRoundRag = useSingleRoundRagMessages(threadId); const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId); const multiRoundRag = useMultiRoundRagMessages(threadId);
const directChat = useDirectChatMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context); const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context); const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context); const frontendRagSelected = isFrontendRagProfile(settings.context);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md // 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。 // 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread); const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -360,13 +364,15 @@ export default function ChatPage() {
if ( if (
singleRoundRag.messages.length === 0 && singleRoundRag.messages.length === 0 &&
ragMessages.length === 0 && ragMessages.length === 0 &&
directChat.messages.length === 0 &&
!singleRoundRag.isLoading && !singleRoundRag.isLoading &&
!multiRoundRag.isLoading !multiRoundRag.isLoading &&
!directChat.isLoading
) { ) {
return base; return base;
} }
const messages = mergeMessagesByTime( const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages], [...base.messages, ...singleRoundRag.messages, ...directChat.messages],
ragMessages, ragMessages,
); );
return { return {
@@ -374,9 +380,14 @@ export default function ChatPage() {
values: { ...base.values, messages }, values: { ...base.values, messages },
messages, messages,
isLoading: isLoading:
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading, base.isLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading,
} as typeof base; } as typeof base;
}, [ }, [
directChat.isLoading,
directChat.messages,
multiRoundRag.isLoading, multiRoundRag.isLoading,
multiRoundRag.messages, multiRoundRag.messages,
researchDisplayThread, researchDisplayThread,
@@ -392,6 +403,52 @@ export default function ChatPage() {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
if (frontendRagSelected) { if (frontendRagSelected) {
if (message.files && message.files.length > 0) { if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。"); toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
@@ -560,6 +617,8 @@ export default function ChatPage() {
multiRoundRag, multiRoundRag,
multiRoundRagSelected, multiRoundRagSelected,
frontendRagSelected, frontendRagSelected,
directChat,
directChatSelected,
routeBase, routeBase,
setIsNewThread, setIsNewThread,
setThreadId, setThreadId,
@@ -655,6 +714,10 @@ export default function ChatPage() {
saveLastAgentId(agentId); saveLastAgentId(agentId);
}, []); }, []);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
if (singleRoundRag.isLoading) { if (singleRoundRag.isLoading) {
singleRoundRag.stop(); singleRoundRag.stop();
return; return;
@@ -664,7 +727,7 @@ export default function ChatPage() {
return; return;
} }
await thread.stop(); await thread.stop();
}, [multiRoundRag, singleRoundRag, thread]); }, [directChat, multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => { const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined; if (!promptPrefixSettings?.enabled) return undefined;
@@ -914,7 +977,8 @@ export default function ChatPage() {
agentHandoffPending || agentHandoffPending ||
knowledgeSelectionLoading || knowledgeSelectionLoading ||
singleRoundRag.isLoading || singleRoundRag.isLoading ||
multiRoundRag.isLoading multiRoundRag.isLoading ||
directChat.isLoading
} }
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertCircle, AlertCircle,
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task)); setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
} }
if (event.type === "completed") toast.success("企业研究报告已完成"); if (event.type === "completed") toast.success("企业研究报告已完成");
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败"); if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
} }
}, },
}).catch((error: unknown) => { }).catch((error: unknown) => {
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
return; return;
} }
if (!isWeKnoraReady) { if (!isWeKnoraReady) {
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora"); toast.error("知识服务尚未启用,请先在系统中配置知识服务");
return; return;
} }
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
<section> <section>
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge> <Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2> <h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p> <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
</section> </section>
<Card> <Card>
@@ -609,12 +610,12 @@ function TaskOverview({
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => { <CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0); const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
const current = task.status === "collecting" && index === 1; const current = task.status === "collecting" && index === 1;
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>; return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
})}</ol></CardContent> })}</ol></CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader> <CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent> <CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
</Card> </Card>
{canPlan ? <Card> {canPlan ? <Card>
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader> <CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
return <Navigate to="/page/workspace/knowledge/assistant" replace />; return <Navigate to="/page/workspace/knowledge/assistant" replace />;
} }
if (runtime.isError || runtime.data?.provider !== "weknora") { if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
if (mode === "list" || mode === "detail") { if (mode === "list" || mode === "detail") {
return <Navigate to="/page/workspace/knowledge/assistant" replace />; return <Navigate to="/page/workspace/knowledge/assistant" replace />;
} }
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`; const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
? (data as { detail: unknown }).detail ? (data as { detail: unknown }).detail
: `请求失败 (${response.status})`; : `请求失败 (${response.status})`;
throw new Error( throw new Error(
typeof detail === "string" ? detail : JSON.stringify(detail), userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
); );
} }
return (await response.json()) as T; return (await response.json()) as T;
@@ -408,6 +409,17 @@ export async function uploadAssistantWeKnoraPackage(input: {
); );
} }
export async function uploadAssistantDocument(input: { file: File; assistantBaseId?: string | null }) {
const body = new FormData();
body.set("file", input.file);
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
return normalizeImportJob(await readJson<AssistantImportJob>(await apiFetch(`${BASE()}/imports/files`, { method: "POST", body })));
}
export async function retryAssistantDocument(jobId: string) {
return readJson<AssistantImportJob>(await apiFetch(`${BASE()}/import-jobs/${encodeURIComponent(jobId)}/retry-file`, { method: "POST" }));
}
export async function deleteAssistantKnowledgeSource(sourceId: string) { export async function deleteAssistantKnowledgeSource(sourceId: string) {
const response = await apiFetch( const response = await apiFetch(
`${BASE()}/sources/${encodeURIComponent(sourceId)}`, `${BASE()}/sources/${encodeURIComponent(sourceId)}`,
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
mutationFn: uploadAssistantWeKnoraPackage, mutationFn: uploadAssistantWeKnoraPackage,
onSuccess: invalidate, onSuccess: invalidate,
}), }),
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
reimportSource: useMutation({ reimportSource: useMutation({
mutationFn: reimportAssistantKnowledgeSource, mutationFn: reimportAssistantKnowledgeSource,
onSuccess: invalidate, onSuccess: invalidate,
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
@@ -85,7 +86,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
const detail = payload && typeof payload === "object" && "detail" in payload const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail) ? String((payload as { detail: unknown }).detail)
: `${context}失败(${response.status})`; : `${context}失败(${response.status})`;
throw new Error(detail); throw new Error(userFacingKnowledgeMessage(detail));
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -249,6 +250,6 @@ export async function deleteEnterpriseResearchTask(taskId: string): Promise<void
const detail = payload && typeof payload === "object" && "detail" in payload const detail = payload && typeof payload === "object" && "detail" in payload
? String((payload as { detail: unknown }).detail) ? String((payload as { detail: unknown }).detail)
: `删除研究任务失败(${response.status})`; : `删除研究任务失败(${response.status})`;
throw new Error(detail); throw new Error(userFacingKnowledgeMessage(detail));
} }
} }
@@ -5,6 +5,7 @@ import { getCsrfToken } from "@/core/api/csrf";
import { getAuthorizationHeaderValue } from "@/core/auth"; import { getAuthorizationHeaderValue } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account"; import { useLiveAccount } from "@/core/auth/use-live-account";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
const API = () => `${getBackendBaseURL()}/api/llmwiki`; const API = () => `${getBackendBaseURL()}/api/llmwiki`;
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`; const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
@@ -366,10 +367,6 @@ export interface LlmWikiWikiPageUpdate {
wiki_path?: string; wiki_path?: string;
} }
function userFacingKnowledgeMessage(value: string): string {
return value.replace(/weknora/gi, "知识服务");
}
async function readJson<T>(response: Response, context: string): Promise<T> { async function readJson<T>(response: Response, context: string): Promise<T> {
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null as unknown); const payload = await response.json().catch(() => null as unknown);
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
@@ -102,7 +103,7 @@ async function readJson<T>(response: Response): Promise<T> {
data && typeof data === "object" && "detail" in data data && typeof data === "object" && "detail" in data
? JSON.stringify((data as { detail: unknown }).detail) ? JSON.stringify((data as { detail: unknown }).detail)
: `请求失败 (${response.status})`; : `请求失败 (${response.status})`;
throw new Error(detail.replace(/^"|"$/g, "")); throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
} }
return (await response.json()) as T; return (await response.json()) as T;
} }
@@ -17,6 +17,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
const [frameError, setFrameError] = useState<string | null>(null); const [frameError, setFrameError] = useState<string | null>(null);
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const runtime = useLlmWikiRuntime(); const runtime = useLlmWikiRuntime();
const landing = useOrdinaryKnowledgeLanding();
const details = useLlmWikiKnowledgeBase( const details = useLlmWikiKnowledgeBase(
knowledgeBaseId || null, knowledgeBaseId || null,
!!knowledgeBaseId, !!knowledgeBaseId,
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || ""; searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
const documentId = searchParams.get("documentId") || ""; const documentId = searchParams.get("documentId") || "";
const wikiSlug = searchParams.get("wikiSlug") || ""; const wikiSlug = searchParams.get("wikiSlug") || "";
const defaultEntry = searchParams.get("view") !== "weknora" && !requestedTab && !wikiSlug && !sourceChunkId && !documentId;
const showNativeIndex = defaultEntry && (landing.data ?? "wiki") === "wiki" && !!detailsWikiEnabled;
const nativeWikiPageUrl = const nativeWikiPageUrl =
knowledgeBaseId && wikiSlug && preferredTab === "wiki" knowledgeBaseId && wikiSlug && preferredTab === "wiki"
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug) ? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
setFrameSrc(""); setFrameSrc("");
setFrameError(null); setFrameError(null);
if ( if (
nativeWikiPageUrl || nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
!knowledgeBaseId || !knowledgeBaseId ||
runtime.data?.provider !== "weknora" || runtime.data?.provider !== "weknora" ||
!detailsReady !detailsReady
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
documentId, documentId,
knowledgeBaseId, knowledgeBaseId,
nativeWikiPageUrl, nativeWikiPageUrl,
landing.isLoading,
showNativeIndex,
preferredTab, preferredTab,
runtime.data?.provider, runtime.data?.provider,
sourceChunkId, sourceChunkId,
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
return <Navigate to={nativeWikiPageUrl} replace />; return <Navigate to={nativeWikiPageUrl} replace />;
} }
if (runtime.isLoading || details.isLoading) { if (runtime.isLoading || details.isLoading || landing.isLoading) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" /> <Loader2 className="mr-2 size-5 animate-spin" />
@@ -262,6 +268,10 @@ export function WeKnoraKnowledgeDetailPage() {
); );
} }
if (showNativeIndex) {
return <Navigate to={`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`} replace />;
}
if (runtime.data?.provider !== "weknora") if (runtime.data?.provider !== "weknora")
return <Navigate to={LIST_PATH} replace />; return <Navigate to={LIST_PATH} replace />;
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-full min-h-0 flex-col bg-background">
{canManageWiki && ( {detailsWikiEnabled && (
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2"> <div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{canEditCurrentWikiArticle && ( {canEditCurrentWikiArticle && (
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
)} )}
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
{isAdmin && ( {isAdmin && (
<Button <Button
size="sm" size="sm"
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
数据包任务 数据包任务
</Button> </Button>
)} )}
<Button {canManageWiki && <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
navigate(
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
)
}
> >
<FileText className="mr-2 size-4" /> <FileText className="mr-2 size-4" />
Wiki管理 Wiki管理
</Button> </Button>}
</div> </div>
</div> </div>
)} )}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
Archive, Archive,
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
</div> </div>
{job.error && ( {job.error && (
<p className="mt-2 text-sm text-destructive"> <p className="mt-2 text-sm text-destructive">
{job.error} {userFacingKnowledgeMessage(job.error)}
</p> </p>
)} )}
</div> </div>
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
const pages = publicEmbed ? publicPages : privatePages; const pages = publicEmbed ? publicPages : privatePages;
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]); const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
const explicitIndexPage = useMemo( const explicitIndexPage = useMemo(
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined), () => remotePagesList.find(isWikiIndexPage),
[publicEmbed, remotePagesList], [publicEmbed, remotePagesList],
); );
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>( const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage () => remotePagesList.length > 0 && !explicitIndexPage
? { ? {
id: "__generated_public_index__", id: "__generated_public_index__",
slug: GENERATED_PUBLIC_INDEX_SLUG, slug: GENERATED_PUBLIC_INDEX_SLUG,
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
[generatedIndexPage, remotePagesList], [generatedIndexPage, remotePagesList],
); );
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]); const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : ""); const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug; const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
const generatedIndexRequested = const generatedIndexRequested =
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage; wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
const privatePage = useLlmWikiWikiPage( const privatePage = useLlmWikiWikiPage(
noteId || null, noteId || null,
wikiSlug || null, wikiSlug || null,
!!noteId && !!wikiSlug && !publicEmbed, !!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
); );
const publicPage = usePublicLlmWikiWikiPage( const publicPage = usePublicLlmWikiWikiPage(
noteId || null, noteId || null,
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
}; };
if (!wikiSlug) { if (!wikiSlug) {
if (publicEmbed && pages.isLoading) { if (pages.isLoading) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" /> <Loader2 className="mr-2 size-5 animate-spin" />
正在加载公开 Wiki 正在加载 Wiki 索引
</div> </div>
); );
} }
if (publicEmbed && pages.error) { if (pages.error) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center"> <div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive"> <div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
} }
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"} 该知识库暂无可展示的 Wiki 页面
{!publicEmbed && ( {!publicEmbed && (
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button> <><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
)} )}
</div> </div>
); );
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>} {globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}> <Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />} {downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
@@ -1,3 +1,4 @@
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Loader2, Upload } from "lucide-react"; import { Download, Loader2, Upload } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
{job.file_size_bytes ? ( {job.file_size_bytes ? (
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span> <span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
) : null} ) : null}
{job.error && <span className="text-destructive">{job.error}</span>} {job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
{assistant && {assistant &&
job.status === "completed" && job.status === "completed" &&
job.file_exists && job.file_exists &&
+13 -6
View File
@@ -1014,9 +1014,11 @@ scheduler runs once per deployment. Per run:
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`) ### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints - `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns - Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias - Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
### IM Channels System (`app/channels/`) ### IM Channels System (`app/channels/`)
@@ -1762,9 +1764,12 @@ gone.
Assistant knowledge has one global full-library base (`知识梳理总库`) and any Assistant knowledge has one global full-library base (`知识梳理总库`) and any
number of admin-created custom bases. Ordinary users have read/search access and number of admin-created custom bases. Ordinary users have read/search access and
administrators own all mutations. Assistant bases only accept WeKnora export administrators own all mutations. Assistant bases accept local documents independently
packages or imports triggered from a WeKnora mapping. Do not add direct skill of WeKnora via `/api/assistant-knowledge/imports/files`, as well as WeKnora export
imports or arbitrary local-file imports to assistant knowledge. Importing into a packages or imports triggered from a WeKnora mapping. Local documents use the
Gateway `assistant_file_ingest` pipeline: persisted original files, chunked LLM Wiki
extraction, cached body embeddings, and import-job status/retry. Do not add direct skill
imports. Importing into a
custom assistant base must also mirror the package into the global base for custom assistant base must also mirror the package into the global base for
cross-source entity alignment. Admins may rename any assistant base and delete cross-source entity alignment. Admins may rename any assistant base and delete
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities, custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
@@ -1773,6 +1778,8 @@ deletable because it owns full-library aggregation.
### Wiki package and local encoder invariants (2026-09) ### Wiki package and local encoder invariants (2026-09)
Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_landing` (`wiki` default / `weknora`), with admin-only PUT `/api/system-settings/ordinary-knowledge-landing`. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; `?view=weknora` explicitly selects the iframe and must not loop back to the default Wiki route.
- `knowledge_transfer.sync_to_global` is called after successful explicit local - `knowledge_transfer.sync_to_global` is called after successful explicit local
Wiki vectorization; it skips conversation/system mappings. Do not enable a new Wiki vectorization; it skips conversation/system mappings. Do not enable a new
watcher or automatic remote-write scheduler as a side effect. watcher or automatic remote-write scheduler as a side effect.
@@ -1,5 +1,11 @@
# DeerFlow Backend # DeerFlow Backend
普通知识库默认详情页面:管理员可在知识库顶部选择“本系统 Wiki 索引”或“嵌入 WeKnora”,对应 `ordinary_knowledge_landing`(默认 `wiki`)。Wiki 库首次进入默认展示本系统索引,右上角“打开 WeKnora”切换到内嵌管理页;内嵌页可返回索引。非 Wiki 库仍进入 WeKnora 文档页。设置通过 `/api/system-settings/ordinary-knowledge-landing` 保存,仅管理员可修改;指定文章、文档、图谱的直达链接优先于默认设置。
助手知识库可独立于 WeKnora 使用:管理员进入助手知识库,选择或创建库后点击“上传文件生成 Wiki”。支持 Markdown/TXT/CSV、PDF、Word、Excel、PowerPoint(单文件 100 MB,解析正文上限 400 万字符)。文件先写入运行目录 `assistant-knowledge/files/{job_id}`,后台分段调用已配置的语言模型生成分类 Wiki、实体关系,再通过 DeerFlow 编码器对正文分段向量化,保存到助手库并汇入知识梳理总库。无需配置 WeKnora URL;配置后仍可直接上传,也保留 WeKnora 数据包导入。文件处理沿用管理员写入权限,所有用户可以浏览和搜索。
导入记录显示解析、Wiki 生成和向量化阶段;失败或服务中断后可“重试 / 恢复处理”,复用已落盘的 Wiki 和相同模型指纹的向量。离线编码器须预先放置模型并在管理员设置中配置;模型或编码器不可用会明确失败,不把摘要冒充 Wiki 或虚构向量。扫描件是否可解析取决于部署的文档解析/OCR 能力。
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments. DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
--- ---
@@ -42,6 +42,7 @@ from app.gateway.routers import (
compact_menu_overrides, compact_menu_overrides,
dashboard_sessions, dashboard_sessions,
deep_research, deep_research,
direct_chat,
external_llmwiki, external_llmwiki,
feedback, feedback,
fixed_questions, fixed_questions,
@@ -1803,6 +1804,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式 # 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router) app.include_router(open_chat.router)
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
app.include_router(direct_chat.router)
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话) # 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
app.include_router(open_3qfx.router) app.include_router(open_3qfx.router)
@@ -0,0 +1,191 @@
"""Local document → Wiki → vectors, independent of the WeKnora service."""
from __future__ import annotations
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any
from deerflow.config.runtime_paths import runtime_home
from deerflow.knowledge.llm_extractor import extract_document_llm_multi
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
ALLOWED_EXTENSIONS = CONVERTIBLE_EXTENSIONS | {".md", ".markdown", ".txt", ".csv"}
MAX_FILE_BYTES = 100 * 1024 * 1024
MAX_TEXT_CHARS = 4_000_000
_tasks: dict[str, asyncio.Task] = {}
_slots = asyncio.Semaphore(2)
_queue_lock = asyncio.Lock()
def file_job_dir(job_id: str) -> Path:
from uuid import UUID
return runtime_home() / "assistant-knowledge" / "files" / str(UUID(job_id))
def save_json(path: Path, value: Any) -> None:
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
temporary.replace(path)
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def split_text(text: str, size: int):
"""Bound every extraction/embedding input without truncating the document."""
for offset in range(0, len(text), size):
yield text[offset : offset + size]
def notes_package(notes: list[dict], source: dict) -> dict:
from deerflow.persistence.assistant_knowledge.store import _entity_alignment_key
merged: dict[str, dict] = {}
for raw in notes:
key = _entity_alignment_key(raw["title"])
if key not in merged:
merged[key] = dict(raw)
continue
note = merged[key]
note["content_md"] = "\n\n".join(dict.fromkeys([note["content_md"], raw["content_md"]]))
for field in ("tags", "entities", "relations"):
note[field] = [*note.get(field, []), *raw.get(field, [])]
note["tags"] = list(dict.fromkeys(note["tags"]))
pages, entities, relations = [], [], []
for index, note in enumerate(merged.values()):
slug = f"files/{source['digest']}/{index}"
pages.append(
{
"slug": slug,
"title": note["title"],
"content": note["content_md"],
"page_type": "article",
"tags": note.get("tags", []),
"category_path": [note.get("category") or "知识文档"],
"source_refs": [{"type": "file", "title": source["filename"]}],
}
)
for entity in note.get("entities", []):
entities.append({"id": entity["name"], "name": entity["name"], "entity_type": entity.get("type", "concept"), "aliases": entity.get("aliases", []), "source_wiki_slug": slug})
for relation in note.get("relations", []):
relations.append({"source": relation["from"], "target": relation["to"], "predicate": relation.get("type", "related_to"), "source_wiki_slug": slug})
# Persist real sibling links, understood by the shared Wiki reader.
for index, page in enumerate(pages):
others = [p for p in pages[max(0, index - 4) : index + 5] if p["slug"] != page["slug"]]
page["out_links"] = [p["slug"] for p in others]
page["in_links"] = list(page["out_links"])
if others:
page["content"] += "\n\n## 相关知识\n" + "\n".join(f"- [[{p['slug']}|{p['title']}]]" for p in others)
return {"source": source, "wiki_pages": pages, "entities": entities, "relations": relations, "vectors": []}
async def run_file_job(app, job_id: str) -> None:
store = app.state.assistant_knowledge_store
root = file_job_dir(job_id)
try:
async with _slots:
manifest = await asyncio.to_thread(read_json, root / "manifest.json")
await store.update_import_job(job_id, status="running", phase="parsing")
package_file = root / "wiki.json"
if package_file.exists():
package = await asyncio.to_thread(read_json, package_file)
else:
text_file = root / "parsed.md"
if not text_file.exists():
original = root / manifest["stored_name"]
if original.suffix in CONVERTIBLE_EXTENSIONS:
converted = await asyncio.to_thread(lambda: asyncio.run(convert_file_to_markdown(original)))
if converted is None:
raise ValueError("无法解析文档,请检查文件是否损坏或是否需要 OCR")
text = await asyncio.to_thread(converted.read_text, encoding="utf-8")
else:
raw = await asyncio.to_thread(original.read_bytes)
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
text = raw.decode("gb18030")
if not text.strip() or len(text) > MAX_TEXT_CHARS:
raise ValueError("文件没有可提取文本,或解析正文超过 400 万字符,请拆分文件")
await asyncio.to_thread(text_file.write_text, text, encoding="utf-8")
else:
text = await asyncio.to_thread(text_file.read_text, encoding="utf-8")
notes = []
parts = list(split_text(text, 10000))
for index, part in enumerate(parts):
await store.update_import_job(job_id, phase="generating_wiki", counts={"parts_total": len(parts), "parts_done": index})
cache = root / f"notes-{index}.json"
if cache.exists():
drafts = await asyncio.to_thread(read_json, cache)
else:
from dataclasses import asdict
extracted = await asyncio.wait_for(
extract_document_llm_multi(
part,
doc_id=f"{job_id}:{index}",
doc_title=manifest["filename"],
max_input_chars=12000,
max_notes=6,
),
timeout=300,
)
if not extracted:
raise ValueError("Wiki 生成失败,请在设置中检查可用的语言模型,然后重试")
drafts = [asdict(note) for note in extracted]
await asyncio.to_thread(save_json, cache, drafts)
notes.extend(drafts)
package = notes_package(notes, {"type": "file", "filename": manifest["filename"], "digest": manifest["digest"]})
await asyncio.to_thread(save_json, package_file, package)
embedding = getattr(app.state, "llmwiki_embedding", None)
if embedding is None:
raise ValueError("Wiki 已生成;请管理员在设置中配置本地编码器或编码服务,然后重试向量化")
vectors = []
sections = [(p, i, section) for p in package["wiki_pages"] for i, section in enumerate(split_text(p["content"], 1600))]
for index, (page, section_index, content) in enumerate(sections):
await store.update_import_job(job_id, phase="vectorizing", counts={"wiki_pages": len(package["wiki_pages"]), "vectors_total": len(sections), "vectors": index})
digest = hashlib.sha256(content.encode()).hexdigest()
cache = root / f"vector-{digest}.json"
record = await asyncio.to_thread(read_json, cache) if cache.exists() else None
if record is None or record["embedding_fingerprint"] != embedding.fingerprint:
values = await embedding.embed_texts([content])
record = {"vector": values[0], "embedding_model": embedding.config.model, "embedding_fingerprint": embedding.fingerprint, "content_hash": digest}
await asyncio.to_thread(save_json, cache, record)
vectors.append({**record, "wiki_slug": page["slug"], "section_index": section_index, "section_content": content})
package["vectors"] = vectors
await store.update_import_job(job_id, phase="importing")
await store.import_package_for_job(job_id, package=package, created_by=manifest["actor"])
except asyncio.CancelledError:
await store.update_import_job(job_id, status="failed", phase="interrupted", error="服务停止,已保留处理进度,可重试", completed=True)
raise
except Exception as exc:
await store.update_import_job(job_id, status="failed", phase="failed", error=str(exc) or type(exc).__name__, completed=True)
def start_file_job(app, job_id: str) -> bool:
if job_id in _tasks:
return False
task = asyncio.create_task(run_file_job(app, job_id))
_tasks[job_id] = task
task.add_done_callback(lambda _: _tasks.pop(job_id, None))
return True
async def stop_file_jobs() -> None:
tasks = list(_tasks.values())
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def retry_file_job(app, job_id: str) -> bool:
async with _queue_lock:
if job_id in _tasks:
return False
await app.state.assistant_knowledge_store.update_import_job(job_id, status="queued", phase="queued")
return start_file_job(app, job_id)
@@ -701,6 +701,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
try: try:
yield yield
finally: finally:
from app.gateway.assistant_file_ingest import stop_file_jobs
await stop_file_jobs()
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime 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) register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
@@ -152,6 +152,18 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None: def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
"""Delete package artifacts owned by one assistant base only.""" """Delete package artifacts owned by one assistant base only."""
import shutil
from app.gateway.assistant_file_ingest import file_job_dir
for job_id in import_job_ids:
# UUID validation confines cleanup to exactly one job directory.
try:
directory = file_job_dir(job_id)
except ValueError:
continue
if directory.is_dir() and not directory.is_symlink():
shutil.rmtree(directory)
root = _package_root() root = _package_root()
if not root.is_dir(): if not root.is_dir():
return return
@@ -448,10 +460,71 @@ async def import_skills(request: Request, _body: SkillImportRequest) -> dict[str
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库") raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
@router.post("/imports/files", status_code=410) @router.post("/imports/files", status_code=202)
async def import_files(request: Request) -> dict[str, Any]: async def import_files(
request: Request,
file: UploadFile = File(...),
assistant_base_id: str | None = Form(default=None),
) -> dict[str, Any]:
import hashlib
from app.gateway.assistant_file_ingest import ALLOWED_EXTENSIONS, MAX_FILE_BYTES, file_job_dir, save_json, start_file_job
actor = await _actor(request, admin=True)
filename = (file.filename or "document").replace("\\", "/").rsplit("/", 1)[-1][:240]
extension = Path(filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
await file.close()
raise HTTPException(status_code=415, detail="支持 Markdown、TXT、CSV、PDF、Word、Excel、PowerPoint 文件")
job = await _store(request).create_queued_import_job(
source_type="file", source_key=f"file-upload:{uuid4()}", source_name=filename,
trigger="local_file_upload", created_by=actor, base_id=assistant_base_id,
metadata={"type": "file", "filename": filename}, phase="uploading",
)
root = file_job_dir(job["id"])
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True)
path = root / f"original{extension}"
size, digest = 0, hashlib.sha256()
try:
with path.open("wb") as handle:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_FILE_BYTES:
raise HTTPException(status_code=413, detail="单个文件最大 100 MB,请拆分后上传")
digest.update(chunk)
await asyncio.to_thread(handle.write, chunk)
if not size:
raise HTTPException(status_code=422, detail="上传文件为空")
await asyncio.to_thread(save_json, root / "manifest.json", {
"filename": filename, "stored_name": path.name, "digest": digest.hexdigest(), "actor": actor,
})
job = await _store(request).update_import_job(job["id"], phase="queued", counts={"uploaded_bytes": size})
start_file_job(request.app, job["id"])
return job
except Exception as exc:
await _store(request).update_import_job(job["id"], status="failed", phase="upload_failed", error=str(getattr(exc, "detail", exc)), completed=True)
await asyncio.to_thread(path.unlink, missing_ok=True)
raise
finally:
await file.close()
@router.post("/import-jobs/{job_id}/retry-file", status_code=202)
async def retry_file_import(request: Request, job_id: str) -> dict[str, Any]:
from app.gateway.assistant_file_ingest import file_job_dir, retry_file_job
await _actor(request, admin=True) await _actor(request, admin=True)
raise HTTPException(status_code=410, detail="助手知识库不支持导入任意本地文件,请从 WeKnora 普通知识库导出后导入") store = _store(request)
job = await store.get_job(job_id)
if not job or job.get("trigger") != "local_file_upload":
raise HTTPException(status_code=404, detail="文件处理任务不存在")
if job["status"] == "completed":
raise HTTPException(status_code=409, detail="文件已处理完成")
if not (file_job_dir(job_id) / "manifest.json").exists():
raise HTTPException(status_code=409, detail="文件上传未完成,请重新上传")
if not await retry_file_job(request.app, job_id):
raise HTTPException(status_code=409, detail="文件仍在处理中")
return await store.get_job(job_id)
@router.post("/imports/weknora-package", status_code=202) @router.post("/imports/weknora-package", status_code=202)
@@ -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",
},
)
@@ -56,6 +56,22 @@ class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
version: Literal["v1", "v2"] version: Literal["v1", "v2"]
class OrdinaryKnowledgeLanding(BaseModel):
mode: Literal["wiki", "weknora"] = "wiki"
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
@router.put("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, request: Request) -> OrdinaryKnowledgeLanding:
await _require_admin(request)
saved = save_system_settings(load_system_settings().model_copy(update={"ordinary_knowledge_landing": body.mode}))
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
class PromptPrefixUpdateRequest(BaseModel): class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None enabled: bool | None = None
switch_label: str | None = None switch_label: str | None = None
+31 -3
View File
@@ -33,6 +33,26 @@ concurrency:
fail_open: true fail_open: true
models: models:
- name: qwen3.6
display_name: qwen3.6
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
when_thinking_disabled:
extra_body:
chat_template_kwargs:
enable_thinking: false
- name: MiniMax-M2.7-fash - name: MiniMax-M2.7-fash
display_name: MiniMax-M2.7-fash display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -59,16 +79,24 @@ models:
supports_vision: false supports_vision: false
- name: qwen3.6 - name: qwen3.6
display_name: qwen3.6 display_name: qwen3.6
use: langchain_openai:ChatOpenAI use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6 model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1 base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0 request_timeout: 30.0
max_retries: 2 max_retries: 2
stream_usage: true stream_usage: true
supports_thinking: false supports_thinking: true
supports_reasoning_effort: false supports_reasoning_effort: false
supports_vision: false supports_vision: false
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
when_thinking_disabled:
extra_body:
chat_template_kwargs:
enable_thinking: false
- name: zai-org/GLM-5-FP8 - name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8 display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -466,7 +494,7 @@ checkpointer:
# 三层覆盖优先级(高->低): # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight) # 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
@@ -123,6 +123,27 @@ models:
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`). If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
**vLLM (per-model, do not change other entries)**:
`use` is per model. SiliconFlow / DeepSeek entries should stay on `langchain_openai:ChatOpenAI`. For a self-hosted vLLM reasoning model (thinking may arrive as `reasoning` on newer vLLM, or `reasoning_content` on older builds), set only that one entry to `VllmChatModel`:
```yaml
models:
- name: qwen3.6
display_name: qwen3.6
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: $VLLM_API_KEY
base_url: http://127.0.0.1:8001/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
```
The default `langchain_openai:ChatOpenAI` path also maps both thinking field names onto the UI's `reasoning_content`. Use `VllmChatModel` when follow-up turns must echo vLLM's original `reasoning` field.
**Thinking Models**: **Thinking Models**:
Some models support "thinking" mode for complex reasoning: Some models support "thinking" mode for complex reasoning:
@@ -270,21 +291,21 @@ skills:
# Host path (optional, default: ../skills) # Host path (optional, default: ../skills)
path: /custom/path/to/skills path: /custom/path/to/skills
# Container mount path (default: /mnt/skills) # Container mount path (default: /mnt/skills)
container_path: /mnt/skills container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill # Optional system-prompt routing rule for the es_query skill
es_query_routing: es_query_routing:
enabled: true enabled: true
prompt: | prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。 当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。 执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
``` ```
`skills.es_query_routing.enabled` controls whether the routing rule is injected `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 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 or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`. contains `es_query`.
**How Skills Work**: **How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/` - Skills are stored in `deer-flow/skills/{public,custom}/`
@@ -40,77 +40,77 @@ logger = logging.getLogger(__name__)
WRITING_SETUP_MAX_ROUNDS = 8 WRITING_SETUP_MAX_ROUNDS = 8
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8 REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">' _ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
# 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".strip()
def _enforce_coordinator_only_tool_exclusions(
agent_id: str | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return excluded_tools
effective = list(excluded_tools or [])
if "agent_orchestration" not in effective:
effective.append("agent_orchestration")
return effective
def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return system_prompt
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
def _filter_available_skills_for_excluded_tools(
available_skills: list[str] | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
if not excluded_tools:
return available_skills
excluded = {name.strip().lower() for name in excluded_tools if name}
blocked = {
skill_name
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
if tool_name in excluded
for skill_name in skill_names
}
if not blocked:
return available_skills
if available_skills is None:
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
try:
from deerflow.skills.storage import get_or_new_skill_storage
available_skills = [
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
]
except Exception: # noqa: BLE001
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
available_skills = []
return [name for name in available_skills if name.strip().lower() not in blocked]
_SKILLS_BLOCKED_BY_EXCLUDED_TOOL: dict[str, frozenset[str]] = {
def _log_es_query_routing_registration( # 内置 tool 与历史 custom skill 目录存在两种拼写;禁用编排工具时两者都必须从系统提示和
# runtime skill allowlist 中消失,防止误配的席位仍看到「加载编排技能」。
"agent_orchestration": frozenset({"agent_orchestration", "agent-orchestration"}),
}
_ROUNDTABLE_COORDINATOR_AGENT_ID = "roundtable-coordinator"
_NON_COORDINATOR_ORCHESTRATION_BOUNDARY = """
<orchestration_capability_boundary>
你不是多智能体圆桌总控,因此没有编排、派活或调度其它智能体的权限。
不得加载、读取、检索或调用 agent_orchestration / agent-orchestration,
不得声称准备使用编排技能;请直接完成当前问答或你的本职任务。
</orchestration_capability_boundary>
""".strip()
def _enforce_coordinator_only_tool_exclusions(
agent_id: str | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""Make orchestration unavailable to every agent except the fixed roundtable coordinator."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return excluded_tools
effective = list(excluded_tools or [])
if "agent_orchestration" not in effective:
effective.append("agent_orchestration")
return effective
def _append_orchestration_capability_boundary(system_prompt: str, agent_id: str | None) -> str:
"""Reinforce the physical tool/skill gate in every non-coordinator system prompt."""
if agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID:
return system_prompt
return f"{system_prompt}\n\n{_NON_COORDINATOR_ORCHESTRATION_BOUNDARY}"
def _filter_available_skills_for_excluded_tools(
available_skills: list[str] | None,
excluded_tools: list[str] | None,
) -> list[str] | None:
"""让工具级禁用同步收紧 skill allowlist,必要时把「全部」展开为安全列表。"""
if not excluded_tools:
return available_skills
excluded = {name.strip().lower() for name in excluded_tools if name}
blocked = {
skill_name
for tool_name, skill_names in _SKILLS_BLOCKED_BY_EXCLUDED_TOOL.items()
if tool_name in excluded
for skill_name in skill_names
}
if not blocked:
return available_skills
if available_skills is None:
# ``None`` 原本表示「全部启用技能可见」,无法表达「除了总控专属技能之外的全部」。
# 此时显式展开存储中的启用技能;扫描失败则 fail closed,避免角色隔离失效。
try:
from deerflow.skills.storage import get_or_new_skill_storage
available_skills = [
skill.name for skill in get_or_new_skill_storage().load_skills(enabled_only=True)
]
except Exception: # noqa: BLE001
logger.warning("Failed to expand skills while applying excluded-tool boundary", exc_info=True)
available_skills = []
return [name for name in available_skills if name.strip().lower() not in blocked]
def _log_es_query_routing_registration(
*, *,
system_prompt: str, system_prompt: str,
app_config: AppConfig, app_config: AppConfig,
@@ -401,9 +401,9 @@ def _build_middlewares(
if summarization_middleware is not None: if summarization_middleware is not None:
middlewares.append(summarization_middleware) middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled # Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False) is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False) is_writing_setup = cfg.get("is_writing_setup", False)
is_report_structure_setup = cfg.get("is_report_structure_setup", False) is_report_structure_setup = cfg.get("is_report_structure_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode) todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
@@ -433,7 +433,9 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True) memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral: memory_cfg = getattr(resolved_app_config, "memory", None)
memory_globally_enabled = memory_cfg is not None and getattr(memory_cfg, "enabled", False)
if memory_globally_enabled and not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled)) middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated # KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
@@ -574,10 +576,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# actually injected. Default off → no behavior change for normal chats. # actually injected. Default off → no behavior change for normal chats.
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False)) thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
reasoning_effort = cfg.get("reasoning_effort", None) reasoning_effort = cfg.get("reasoning_effort", None)
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model") requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
is_plan_mode = cfg.get("is_plan_mode", False) is_plan_mode = cfg.get("is_plan_mode", False)
disable_tools = bool(cfg.get("disable_tools", False)) disable_tools = bool(cfg.get("disable_tools", False))
subagent_enabled = cfg.get("subagent_enabled", False) subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、 # 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
@@ -603,11 +605,11 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
if not is_writing_mode_report_path(writing_artifact_path): if not is_writing_mode_report_path(writing_artifact_path):
writing_artifact_path = None writing_artifact_path = None
agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name")) agent_id = validate_agent_id(cfg.get("agent_id") or cfg.get("agent_name"))
requested_excluded_tools = _enforce_coordinator_only_tool_exclusions( requested_excluded_tools = _enforce_coordinator_only_tool_exclusions(
agent_id, agent_id,
cfg.get("excluded_tools") or None, cfg.get("excluded_tools") or None,
) )
try: try:
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup and not is_report_structure_setup else None
@@ -656,14 +658,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills from deerflow.agents.deep_research.retrieval import merge_collector_skill_allowlist, runtime_extra_skills
runtime_available_skills = merge_collector_skill_allowlist( runtime_available_skills = merge_collector_skill_allowlist(
agent_id, runtime_available_skills, runtime_extra_skills(cfg) agent_id, runtime_available_skills, runtime_extra_skills(cfg)
) )
runtime_available_skills = _filter_available_skills_for_excluded_tools( runtime_available_skills = _filter_available_skills_for_excluded_tools(
runtime_available_skills, runtime_available_skills,
requested_excluded_tools, requested_excluded_tools,
) )
prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None prompt_available_skills = set(runtime_available_skills) if runtime_available_skills is not None else None
config["metadata"].update( config["metadata"].update(
{ {
@@ -744,14 +746,14 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Special bootstrap agent with minimal prompt for initial custom agent creation flow # Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent( return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config), model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
tools=get_available_tools( tools=get_available_tools(
model_name=model_name, model_name=model_name,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config, app_config=resolved_app_config,
) )
+ [setup_agent], + [setup_agent],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
system_prompt=apply_prompt_template( system_prompt=apply_prompt_template(
@@ -771,19 +773,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Default lead agent (unchanged behavior) # Default lead agent (unchanged behavior)
is_notebook_mode = bool(cfg.get("notebook_search_space_id")) is_notebook_mode = bool(cfg.get("notebook_search_space_id"))
notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or [] notebook_source_ids: list[int] = cfg.get("notebook_source_ids") or []
default_tools = ( default_tools = (
[] []
if disable_tools if disable_tools
else get_available_tools( else get_available_tools(
model_name=model_name, model_name=model_name,
groups=agent_config.tool_groups if agent_config else None, groups=agent_config.tool_groups if agent_config else None,
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
is_scheduled_run=cfg.get("is_scheduled_run", False), is_scheduled_run=cfg.get("is_scheduled_run", False),
excluded_tools=requested_excluded_tools, excluded_tools=requested_excluded_tools,
allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID, allow_agent_orchestration=agent_id == _ROUNDTABLE_COORDINATOR_AGENT_ID,
app_config=resolved_app_config, app_config=resolved_app_config,
) )
) )
if agent_id == FORCED_RESEARCH_AGENT_ID: if agent_id == FORCED_RESEARCH_AGENT_ID:
default_tools = filter_forced_research_tools(default_tools) default_tools = filter_forced_research_tools(default_tools)
if is_notebook_mode: if is_notebook_mode:
@@ -825,8 +827,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_mode=writing_mode, writing_mode=writing_mode,
writing_artifact_path=writing_artifact_path, writing_artifact_path=writing_artifact_path,
) )
else: else:
system_prompt = apply_prompt_template( system_prompt = apply_prompt_template(
subagent_enabled=subagent_enabled, subagent_enabled=subagent_enabled,
max_concurrent_subagents=max_concurrent_subagents, max_concurrent_subagents=max_concurrent_subagents,
agent_id=agent_id, agent_id=agent_id,
@@ -839,22 +841,22 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
rag_mode_enabled=rag_mode_enabled, rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode, is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids, notebook_source_ids=notebook_source_ids,
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)), is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
) )
# 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是 # 前端可提供当前视觉明暗模式给页面生成类任务。把值放入系统提示而不是
# 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。 # 指望模型从 ToolRuntime.context 猜测,确保自定义 SOUL 也能稳定使用。
presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower() presentation_theme = str(cfg.get("presentation_theme") or "").strip().lower()
if presentation_theme in {"dark", "light"}: if presentation_theme in {"dark", "light"}:
system_prompt += ( system_prompt += (
"\n\n【当前界面主题】\n" "\n\n【当前界面主题】\n"
f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;" f"当前页面呈现主题为 `{presentation_theme}`。若本轮生成汇报页面,请优先选择同色系 HTML 模板;"
"除非用户明确要求覆盖该主题。" "除非用户明确要求覆盖该主题。"
) )
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id) system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
_log_es_query_routing_registration( _log_es_query_routing_registration(
system_prompt=system_prompt, system_prompt=system_prompt,
app_config=resolved_app_config, app_config=resolved_app_config,
agent_id=agent_id, agent_id=agent_id,
@@ -9,8 +9,8 @@ from functools import lru_cache
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -487,7 +487,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
</subagent_system>""" </subagent_system>"""
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
<ordinary_qa_markdown_format> <ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式: 当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。 - 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
@@ -496,19 +496,19 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。 - 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。 - 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format> </ordinary_qa_markdown_format>
""" """
def _ordinary_qa_markdown_format_enabled() -> bool: def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch.""" """Return the admin-controlled formal Markdown prompt switch."""
try: try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled) return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception: except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True) logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False return False
SYSTEM_PROMPT_TEMPLATE = """ SYSTEM_PROMPT_TEMPLATE = """
<role> <role>
You are {agent_name}, an internal super agent. You are {agent_name}, an internal super agent.
</role> </role>
@@ -883,11 +883,16 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
return "" return ""
try: try:
from deerflow.agents.memory.manager import build_memory_manager from deerflow.agents.memory.manager import build_memory_manager
from deerflow.config.memory_config import get_effective_memory_config from deerflow.config.memory_config import get_effective_memory_config, get_memory_config
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
if not get_memory_config().enabled:
return ""
user_id = get_effective_user_id() user_id = get_effective_user_id()
config = get_effective_memory_config(user_id) config = get_effective_memory_config(user_id)
if not config.enabled or not config.injection.enabled:
return ""
manager = build_memory_manager( manager = build_memory_manager(
user_id=user_id, user_id=user_id,
@@ -1547,13 +1552,13 @@ When a search, knowledge, skill, or MCP tool returns a result list:
if rag_mode_enabled if rag_mode_enabled
else "" else ""
) )
ordinary_qa_format_section = ( ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION _ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if _ordinary_qa_markdown_format_enabled() if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap and not is_bootstrap
and not writing_mode and not writing_mode
and not is_notebook_mode and not is_notebook_mode
and not is_scheduled_run and not is_scheduled_run
else "" else ""
) )
@@ -16,7 +16,7 @@
============ ============
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低): ``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取) 1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True) (仅白名单字段,不能覆盖 ``enabled``)
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``) 2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
3. Pydantic 默认值 3. Pydantic 默认值
@@ -254,13 +254,12 @@ def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。 """返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。 优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
有 per-user 记录时自动强制 enabled=True。 ``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。
""" """
base = _memory_config.model_dump() base = _memory_config.model_dump()
recall_budget = _recall_budget_cache.get(user_id) recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None: if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget base.setdefault("hindsight", {})["recall_budget"] = recall_budget
base["enabled"] = True
try: try:
return MemoryConfig(**base) return MemoryConfig(**base)
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级 except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
@@ -538,6 +538,7 @@ class SystemSettings(BaseModel):
default="v1", default="v1",
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.", description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
) )
ordinary_knowledge_landing: Literal["wiki", "weknora"] = "wiki"
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
return disable_kwargs return disable_kwargs
# Qwen3.8 chat templates only accept these three effort names.
_QWEN_REASONING_EFFORT = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "xhigh",
"xhigh": "xhigh",
}
_OPENAI_THINKING_OFF_EXTRA_BODY = {
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def _is_vllm_provider(model_use: str) -> bool:
lowered = (model_use or "").lower()
return "vllm_provider" in lowered or lowered.endswith(":vllmchatmodel")
def _is_qwen38_model(model_config) -> bool:
blob = f"{getattr(model_config, 'name', '')} {getattr(model_config, 'model', '')}".lower()
return "qwen3.8" in blob
def _should_control_openai_thinking(model_config) -> bool:
"""Whether this OpenAI-compatible model needs explicit thinking on/off flags.
Qwen3.8 (and current vLLM reasoning parsers) default to thinking at ``xhigh``.
If DeerFlow does not send ``enable_thinking: false``, the first visible answer
token waits until that hidden reasoning finishes — third-party chat UIs that
disable thinking or stream the reasoning field therefore feel much faster.
"""
if getattr(model_config, "supports_thinking", False):
return True
if _is_vllm_provider(getattr(model_config, "use", "")):
return True
return _is_qwen38_model(model_config)
def _map_qwen_reasoning_effort(effort: object) -> str | None:
if effort is None:
return None
return _QWEN_REASONING_EFFORT.get(str(effort).strip().lower())
def _apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config: dict,
*,
thinking_enabled: bool,
reasoning_effort: object,
) -> None:
"""Inject vLLM/Qwen thinking flags when the UI toggle is on or off."""
if "extra_body" not in getattr(model_class, "model_fields", {}):
return
qwen_vllm = _is_vllm_provider(getattr(model_config, "use", "")) or _is_qwen38_model(model_config)
if thinking_enabled:
if not qwen_vllm:
return
enable_body: dict = {
"enable_thinking": True,
"chat_template_kwargs": {"enable_thinking": True},
}
mapped_effort = _map_qwen_reasoning_effort(reasoning_effort)
if mapped_effort:
enable_body["chat_template_kwargs"]["reasoning_effort"] = mapped_effort
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
enable_body,
)
return
if not _should_control_openai_thinking(model_config):
return
disable_body = (
{
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
}
if qwen_vllm
else _OPENAI_THINKING_OFF_EXTRA_BODY
)
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
disable_body,
)
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None: def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
"""Enable stream usage for OpenAI-compatible models unless explicitly configured. """Enable stream usage for OpenAI-compatible models unless explicitly configured.
@@ -127,10 +217,19 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"): elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter # Native langchain_anthropic: thinking is a direct constructor parameter
model_settings_from_config["thinking"] = {"type": "disabled"} model_settings_from_config["thinking"] = {"type": "disabled"}
requested_reasoning_effort = kwargs.get("reasoning_effort")
if not model_config.supports_reasoning_effort: if not model_config.supports_reasoning_effort:
kwargs.pop("reasoning_effort", None) kwargs.pop("reasoning_effort", None)
model_settings_from_config.pop("reasoning_effort", None) model_settings_from_config.pop("reasoning_effort", None)
_apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config,
thinking_enabled=thinking_enabled,
reasoning_effort=requested_reasoning_effort,
)
# Hard override: force-disable thinking regardless of the per-model config shape. # Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`); # Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it). # other providers would reject the kwarg, so they are skipped (they ignore it).
@@ -139,15 +238,15 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
# (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the # (DeepSeek-V3.1+/Qwen3/GLM hybrid models on api.siliconflow.cn — the knob the
# nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native # nested ``chat_template_kwargs`` shape does NOT reach), vLLM/Qwen native
# ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``. # ``chat_template_kwargs.enable_thinking``, and Zhipu GLM ``thinking.type``.
# Only models that explicitly opt into thinking should receive vendor-specific # Only models that explicitly opt into thinking should receive vendor-specific
# disable payloads. Generic OpenAI-compatible gateways (including LiteLLM) # disable payloads. Generic OpenAI-compatible gateways (including LiteLLM)
# may reject these otherwise-unknown fields instead of forwarding them. # may reject these otherwise-unknown fields instead of forwarding them.
if ( if (
force_disable_thinking force_disable_thinking
and not thinking_enabled and not thinking_enabled
and model_config.supports_thinking and model_config.supports_thinking
and "extra_body" in getattr(model_class, "model_fields", {}) and "extra_body" in getattr(model_class, "model_fields", {})
): ):
model_settings_from_config["extra_body"] = _deep_merge_dicts( model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"), model_settings_from_config.get("extra_body"),
{"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}}, {"enable_thinking": False, "chat_template_kwargs": {"enable_thinking": False}, "thinking": {"type": "disabled"}},
@@ -29,11 +29,12 @@ from typing import Any
from langchain_core.language_models import LanguageModelInput from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, extract_reasoning_text, fill_ui_reasoning_content, restore_reasoning_fields
class PatchedChatOpenAI(ChatOpenAI): class PatchedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway. """ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
@@ -89,16 +90,16 @@ class PatchedChatOpenAI(ChatOpenAI):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_tool_call_signatures(payload_msg, orig_msg) _restore_tool_call_signatures(payload_msg, orig_msg)
_restore_reasoning_content(payload_msg, orig_msg) _restore_reasoning_content(payload_msg, orig_msg)
else: else:
# Fallback: match assistant-role entries positionally against AIMessages. # Fallback: match assistant-role entries positionally against AIMessages.
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg) _restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg) _restore_reasoning_content(payload_msg, ai_msg)
normalize_chat_payload_messages(payload) normalize_chat_payload_messages(payload)
return payload return payload
def _convert_chunk_to_generation_chunk( def _convert_chunk_to_generation_chunk(
self, self,
@@ -118,12 +119,12 @@ class PatchedChatOpenAI(ChatOpenAI):
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", []) choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
choice = choices[0] if choices else {} choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None) if isinstance(generation_chunk.message, AIMessageChunk):
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk): generation_chunk.message = apply_reasoning_from_payload(
generation_chunk.message = _append_reasoning_content(
generation_chunk.message, generation_chunk.message,
reasoning_content, delta if isinstance(delta, dict) else None,
) )
generation_chunk.message = fill_ui_reasoning_content(generation_chunk.message)
return generation_chunk return generation_chunk
def _create_chat_result( def _create_chat_result(
@@ -141,13 +142,19 @@ class PatchedChatOpenAI(ChatOpenAI):
message = generation.message message = generation.message
choice = choices[index] if index < len(choices) else {} choice = choices[index] if index < len(choices) else {}
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {} choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None) if isinstance(message, AIMessage):
if reasoning_content and isinstance(message, AIMessage): updated = apply_reasoning_from_payload(
message = _append_reasoning_content(message, reasoning_content) message,
generation = ChatGeneration( choice_message if isinstance(choice_message, dict) else None,
message=message, append=False,
generation_info=generation.generation_info,
) )
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) generations.append(generation)
return ChatResult(generations=generations, llm_output=result.llm_output) return ChatResult(generations=generations, llm_output=result.llm_output)
@@ -195,33 +202,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None: def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject ``reasoning_content`` onto assistant payload message. """Re-inject thinking fields onto assistant payload message.
Some thinking-enabled OpenAI-compatible providers require each historical Some thinking-enabled OpenAI-compatible providers require each historical
assistant turn to include the original ``reasoning_content`` in subsequent assistant turn to include the original thinking field in subsequent
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
not always serialize it back into ``messages``. vLLM use ``reasoning_content``. Echo only the fields this turn originally
carried so other models are not given an extra field.
""" """
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content") restore_reasoning_fields(payload_msg, orig_msg)
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
def _extract_reasoning_content(payload: dict | None) -> str | None: def _extract_reasoning_content(payload: dict | None) -> str | None:
"""Extract reasoning text from provider payload delta/message objects.""" """Extract reasoning text from provider payload delta/message objects."""
if not isinstance(payload, dict): return extract_reasoning_text(payload)
return None
reasoning_content = payload.get("reasoning_content")
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk: def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
"""Append streamed reasoning chunks, dedupe complete response reasoning.""" """Append streamed reasoning chunks, dedupe complete response reasoning."""
additional_kwargs = dict(message.additional_kwargs) return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
existing = additional_kwargs.get("reasoning_content")
if isinstance(existing, str) and existing:
if reasoning_content not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
else:
additional_kwargs["reasoning_content"] = reasoning_content
return message.model_copy(update={"additional_kwargs": additional_kwargs})
@@ -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 thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
be echoed back on subsequent turns. be echoed back on subsequent turns.
This provider preserves ``reasoning`` on: This provider preserves ``reasoning`` (newer vLLM) and
- non-streaming responses ``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
- streaming deltas - non-streaming responses
- multi-turn request payloads - streaming deltas
- multi-turn request payloads
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
""" """
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any, cast from typing import Any, cast
@@ -32,11 +33,12 @@ from langchain_core.messages import (
) )
from langchain_core.messages.tool import tool_call_chunk from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
"""Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``. """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: def _reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from vLLM payloads.""" """Best-effort extraction of readable reasoning text from vLLM payloads."""
if isinstance(reasoning, str): from deerflow.models.reasoning_fields import reasoning_to_text
return reasoning
if isinstance(reasoning, list): return reasoning_to_text(reasoning)
parts = [_reasoning_to_text(item) for item in reasoning]
return "".join(part for part in parts if part)
if isinstance(reasoning, dict):
for key in ("text", "content", "reasoning"):
value = reasoning.get(key)
if isinstance(value, str):
return value
if value is not None:
text = _reasoning_to_text(value)
if text:
return text
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk: def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
@@ -106,12 +86,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
function_call["name"] = "" function_call["name"] = ""
additional_kwargs["function_call"] = function_call additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning") seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
if reasoning is not None: additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
additional_kwargs["reasoning_content"] = reasoning_text
tool_call_chunks = [] tool_call_chunks = []
if raw_tool_calls := _dict.get("tool_calls"): if raw_tool_calls := _dict.get("tool_calls"):
@@ -151,11 +127,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None: def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
"""Re-inject vLLM reasoning onto outgoing assistant messages.""" """Re-inject vLLM reasoning onto outgoing assistant messages."""
reasoning = orig_msg.additional_kwargs.get("reasoning") restore_reasoning_fields(payload_msg, orig_msg)
if reasoning is None:
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
class VllmChatModel(ChatOpenAI): class VllmChatModel(ChatOpenAI):
@@ -184,14 +156,14 @@ class VllmChatModel(ChatOpenAI):
for payload_msg, orig_msg in zip(payload_messages, original_messages): for payload_msg, orig_msg in zip(payload_messages, original_messages):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_field(payload_msg, orig_msg) _restore_reasoning_field(payload_msg, orig_msg)
else: else:
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg) _restore_reasoning_field(payload_msg, ai_msg)
normalize_chat_payload_messages(payload) normalize_chat_payload_messages(payload)
return payload return payload
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult: def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
"""Preserve vLLM reasoning on non-streaming responses.""" """Preserve vLLM reasoning on non-streaming responses."""
@@ -204,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
message = generation.message message = generation.message
if not isinstance(message, AIMessage): if not isinstance(message, AIMessage):
continue continue
reasoning = choice.get("message", {}).get("reasoning") choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
if reasoning is None: generation.message = apply_reasoning_from_payload(
continue message,
message.additional_kwargs["reasoning"] = reasoning choice_message if isinstance(choice_message, dict) else None,
reasoning_text = _reasoning_to_text(reasoning) append=False,
if reasoning_text: )
message.additional_kwargs["reasoning_content"] = reasoning_text
return result return result
@@ -241,6 +241,8 @@ class AssistantKnowledgeRepository:
) -> tuple[str, str]: ) -> tuple[str, str]:
metadata = package.get("source") or {} metadata = package.get("source") or {}
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold() provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
if provider == "file" and metadata.get("digest"):
return f"file:{metadata['digest']}", str(metadata.get("filename") or fallback_name)
if provider == "weknora": if provider == "weknora":
source_key = str( source_key = str(
metadata.get("knowledge_base_id") metadata.get("knowledge_base_id")
@@ -0,0 +1,100 @@
import asyncio
from types import SimpleNamespace
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import assistant_file_ingest as ingest
from app.gateway.routers import assistant_knowledge as router
from deerflow.knowledge.schemas import NoteDraft
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
from deerflow.persistence.base import Base
def test_same_topic_across_document_parts_preserves_all_knowledge():
package = ingest.notes_package([
{"title": "设备维护", "content_md": "每周清理过滤网", "tags": ["运维"]},
{"title": "设备 维护", "content_md": "每月检查电机温度", "tags": ["设备"]},
], {"digest": "document", "filename": "维护指南.md"})
assert len(package["wiki_pages"]) == 1
assert "每周清理过滤网" in package["wiki_pages"][0]["content"]
assert "每月检查电机温度" in package["wiki_pages"][0]["content"]
assert package["wiki_pages"][0]["tags"] == ["运维", "设备"]
@pytest.mark.asyncio
async def test_standalone_upload_vectors_retry_and_dedup(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
store = AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
base = await store.create_base(name="独立文档库", created_by="admin")
app = FastAPI()
app.include_router(router.router)
app.state.assistant_knowledge_store = store
app.state.llmwiki_embedding = None
monkeypatch.setattr(ingest, "runtime_home", lambda: tmp_path)
extraction_calls = []
async def actor(*args, **kwargs):
return "admin"
async def extract(text, **kwargs):
extraction_calls.append(text)
return [NoteDraft(title="冷却系统", summary="摘要占位", content_md="# 冷却系统\n风扇的额定转速为每分钟 1200 转。", tags=["设备"], entities=[{"name": "风扇", "type": "product"}])]
class Encoder:
fingerprint = "local-test-3"
dimensions = 3
config = SimpleNamespace(model="offline-test")
async def embed_texts(self, texts):
return [[1.0, 0.0, 0.0] for _ in texts]
async def embed_query(self, text):
return [1.0, 0.0, 0.0]
monkeypatch.setattr(router, "_actor", actor)
monkeypatch.setattr(ingest, "extract_document_llm_multi", extract)
async def finish():
if ingest._tasks:
await asyncio.gather(*list(ingest._tasks.values()))
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
payload = {"assistant_base_id": base["id"]}
response = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("../../设备.md", "风扇额定转速为 1200 转。".encode())})
assert response.status_code == 202, response.text
job_id = response.json()["id"]
await finish()
assert (await store.get_job(job_id))["status"] == "failed"
assert (ingest.file_job_dir(job_id) / "wiki.json").exists()
app.state.llmwiki_embedding = Encoder()
retry = await client.post(f"/api/assistant-knowledge/import-jobs/{job_id}/retry-file")
assert retry.status_code == 202
await finish()
job = await store.get_job(job_id)
assert job["status"] == "completed", job
assert job["counts"]["vectors"] == 1
assert len(extraction_calls) == 1
results = await store.search_pages(base_ids=[base["id"]], query="额定转速", embedding_client=app.state.llmwiki_embedding)
assert results and results[0]["retrieval_mode"] == "vector"
assert "1200" in results[0]["matched_section"]["content"]
assert "摘要占位" not in results[0]["matched_section"]["content"]
global_base = await store.get_base()
assert len(await store.list_pages(base_id=global_base["id"])) == 1
again = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("设备.md", "风扇额定转速为 1200 转。".encode())})
assert again.status_code == 202
await finish()
assert len(await store.list_sources(base_id=base["id"])) == 1
assert len(await store.list_pages(base_id=global_base["id"])) == 1
bad = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("script.py", b"print('no')")})
assert bad.status_code == 415
empty = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("empty.txt", b"")})
assert empty.status_code == 422
finally:
await ingest.stop_file_jobs()
await engine.dispose()
@@ -239,3 +239,67 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup
) )
assert chat_model.extra_body is None assert chat_model.extra_body is None
def _extra_body(chat_model) -> dict:
body = chat_model.extra_body or {}
return body if isinstance(body, dict) else dict(body)
def test_qwen38_flash_mode_disables_default_xhigh_thinking() -> None:
"""Qwen3.8 defaults to thinking=xhigh; flash must send enable_thinking=false."""
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="Qwen/Qwen3.8-27B",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=False,
)
]
)
chat_model = create_chat_model("qwen3.8-27b", thinking_enabled=False, app_config=config)
extra_body = _extra_body(chat_model)
assert extra_body.get("enable_thinking") is False
assert extra_body.get("chat_template_kwargs", {}).get("enable_thinking") is False
def test_qwen38_thinking_mode_sends_medium_reasoning_effort() -> None:
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="qwen3.8-27b",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=True,
)
]
)
chat_model = create_chat_model(
"qwen3.8-27b",
thinking_enabled=True,
reasoning_effort="medium",
app_config=config,
)
extra_body = _extra_body(chat_model)
kwargs = extra_body.get("chat_template_kwargs") or {}
assert extra_body.get("enable_thinking") is True
assert kwargs.get("enable_thinking") is True
assert kwargs.get("reasoning_effort") == "medium"
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
from deerflow.models.patched_openai import _extract_reasoning_content
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理"
assert _extract_reasoning_content({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
assert _extract_reasoning_content({"content": "答案"}) is None
@@ -0,0 +1,111 @@
"""Reasoning field compatibility: vLLM `reasoning` vs older `reasoning_content`."""
from __future__ import annotations
from langchain_core.messages import AIMessage, AIMessageChunk
from deerflow.models.patched_openai import _extract_reasoning_content, _restore_reasoning_content
from deerflow.models.reasoning_fields import (
apply_reasoning_from_payload,
extract_reasoning_text,
fill_ui_reasoning_content,
restore_reasoning_fields,
)
from deerflow.models.vllm_provider import (
_convert_delta_to_message_chunk_with_reasoning,
_restore_reasoning_field,
)
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
assert extract_reasoning_text({"reasoning": "先分析问题"}) == "先分析问题"
assert extract_reasoning_text({"reasoning_content": "逐步推理"}) == "逐步推理"
assert extract_reasoning_text({"content": "答案"}) is None
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
def test_extract_reasoning_reads_structured_reasoning_object() -> None:
assert extract_reasoning_text({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
assert extract_reasoning_text({"reasoning": {"text": "逐步分析"}}) == "逐步分析"
def test_extract_prefers_reasoning_content_when_both_present() -> None:
assert (
extract_reasoning_text({"reasoning_content": "旧字段", "reasoning": "新字段"})
== "旧字段"
)
def test_apply_new_vllm_reasoning_keeps_ui_field_and_wire_field() -> None:
message = apply_reasoning_from_payload(AIMessage(content="答案"), {"reasoning": "新版思考"})
assert message.additional_kwargs["reasoning"] == "新版思考"
assert message.additional_kwargs["reasoning_content"] == "新版思考"
def test_apply_old_reasoning_content_does_not_invent_reasoning() -> None:
message = apply_reasoning_from_payload(
AIMessage(content="答案"),
{"reasoning_content": "旧版思考"},
)
assert "reasoning" not in message.additional_kwargs
assert message.additional_kwargs["reasoning_content"] == "旧版思考"
def test_restore_new_vllm_echoes_reasoning_only() -> None:
orig = AIMessage(
content="答案",
additional_kwargs={"reasoning": "新版思考", "reasoning_content": "新版思考"},
)
payload: dict = {"role": "assistant", "content": "答案"}
restore_reasoning_fields(payload, orig)
assert payload["reasoning"] == "新版思考"
assert "reasoning_content" not in payload
def test_restore_old_vllm_echoes_reasoning_content_only() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
payload: dict = {"role": "assistant", "content": "答案"}
restore_reasoning_fields(payload, orig)
assert payload["reasoning_content"] == "旧版思考"
assert "reasoning" not in payload
def test_patched_openai_restore_does_not_add_reasoning_for_deepseek_turns() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "DeepSeek思考"})
payload: dict = {"role": "assistant", "content": "答案"}
_restore_reasoning_content(payload, orig)
assert payload == {"role": "assistant", "content": "答案", "reasoning_content": "DeepSeek思考"}
def test_vllm_stream_delta_reasoning_maps_to_ui_field() -> None:
chunk = _convert_delta_to_message_chunk_with_reasoning(
{"role": "assistant", "content": "", "reasoning": "流式思考"},
AIMessageChunk,
)
assert isinstance(chunk, AIMessageChunk)
assert chunk.additional_kwargs["reasoning"] == "流式思考"
assert chunk.additional_kwargs["reasoning_content"] == "流式思考"
def test_vllm_stream_delta_reasoning_content_does_not_copy_into_reasoning() -> None:
chunk = _convert_delta_to_message_chunk_with_reasoning(
{"role": "assistant", "content": "", "reasoning_content": "旧流式思考"},
AIMessageChunk,
)
assert isinstance(chunk, AIMessageChunk)
assert "reasoning" not in chunk.additional_kwargs
assert chunk.additional_kwargs["reasoning_content"] == "旧流式思考"
def test_vllm_restore_old_field_stays_on_reasoning_content() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
payload: dict = {"role": "assistant", "content": "答案"}
_restore_reasoning_field(payload, orig)
assert payload["reasoning_content"] == "旧版思考"
assert "reasoning" not in payload
def test_fill_ui_reasoning_content_from_langchain_kwargs() -> None:
message = AIMessage(content="答案", additional_kwargs={"reasoning": "网关已放入reasoning"})
filled = fill_ui_reasoning_content(message)
assert filled.additional_kwargs["reasoning_content"] == "网关已放入reasoning"
+1 -1
View File
@@ -264,7 +264,7 @@ ai_writing:
# 三层覆盖优先级(高->低): # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处 # users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: true enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: v1 version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名) provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个 # builtin 始终启用,这里只决定 external 选哪个