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