Compare commits
6
Commits
master-nj
...
e243ec3a46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e243ec3a46 | ||
|
|
f4c7d41a60 | ||
|
|
d765afe2ac | ||
|
|
4f7f07504a | ||
|
|
3ca5528640 | ||
|
|
f9b6d7e322 |
No files matched your search
@@ -100,6 +100,8 @@ offline-backend-20260512/backend/tests/*
|
|||||||
# Keep the model configuration regression coverage versioned even though
|
# Keep the model configuration regression coverage versioned even though
|
||||||
# this deployment repository otherwise ignores newly added backend tests.
|
# this deployment repository otherwise ignores newly added backend tests.
|
||||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||||
|
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
|
||||||
|
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
|
||||||
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
|
||||||
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
|
||||||
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
# Keep fixed-question exact-match and streaming regression coverage versioned.
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
|
||||||
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
|
||||||
# VITE_COMPACT_MENU_TYPE=A
|
# VITE_COMPACT_MENU_TYPE=A
|
||||||
|
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
|
||||||
|
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
|
||||||
|
# VITE_TURBO_MODE_VISIBLE=false
|
||||||
# VITE_GITHUB_OAUTH_TOKEN=
|
# VITE_GITHUB_OAUTH_TOKEN=
|
||||||
|
|
||||||
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
|
|||||||
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
|
||||||
VITE_GITHUB_OAUTH_TOKEN: "",
|
VITE_GITHUB_OAUTH_TOKEN: "",
|
||||||
|
|
||||||
|
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
|
||||||
|
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
|
||||||
|
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
|
||||||
|
VITE_TURBO_MODE_VISIBLE: "true",
|
||||||
|
|
||||||
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
|
||||||
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
|
||||||
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
VITE_SENTIMENT_AGENT_VISIBLE: "true",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
|
GaugeIcon,
|
||||||
GraduationCapIcon,
|
GraduationCapIcon,
|
||||||
LightbulbIcon,
|
LightbulbIcon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -68,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
|||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import type { Model } from "@/core/models/types";
|
import type { Model } from "@/core/models/types";
|
||||||
import type { AgentThreadContext } from "@/core/threads";
|
import type { AgentThreadContext } from "@/core/threads";
|
||||||
|
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { desensitize } from "@/lib/desensitize";
|
import { desensitize } from "@/lib/desensitize";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -223,7 +225,11 @@ export function InputBox({
|
|||||||
context.execution_profile === "single_round_rag";
|
context.execution_profile === "single_round_rag";
|
||||||
const multiRoundRagActive =
|
const multiRoundRagActive =
|
||||||
context.execution_profile === "multi_round_rag";
|
context.execution_profile === "multi_round_rag";
|
||||||
|
const turboModeVisible = isTurboModeVisible();
|
||||||
|
const directChatActive =
|
||||||
|
turboModeVisible && context.execution_profile === "direct";
|
||||||
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||||
|
const bypassAgentActive = frontendRagActive || directChatActive;
|
||||||
const modelOptions = useMemo(() => {
|
const modelOptions = useMemo(() => {
|
||||||
return singleRoundRagActive ? extraModels : models;
|
return singleRoundRagActive ? extraModels : models;
|
||||||
}, [extraModels, models, singleRoundRagActive]);
|
}, [extraModels, models, singleRoundRagActive]);
|
||||||
@@ -253,11 +259,37 @@ export function InputBox({
|
|||||||
recommendedQuestions.length > 0;
|
recommendedQuestions.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Multi-round RAG is a frontend execution profile, not a model option.
|
// Frontend-only execution profiles are not model options.
|
||||||
// Do not let the normal model fallback clear it on every render.
|
// Do not let the normal model fallback clear them on every render.
|
||||||
|
if (!turboModeVisible && context.execution_profile === "direct") {
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
execution_profile: undefined,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (multiRoundRagActive) {
|
if (multiRoundRagActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (directChatActive) {
|
||||||
|
if (models.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentModel = models.find((m) => m.name === context.model_name);
|
||||||
|
const fallbackModel = currentModel ?? models[0]!;
|
||||||
|
if (
|
||||||
|
context.model_name === fallbackModel.name &&
|
||||||
|
context.execution_profile === "direct"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
model_name: fallbackModel.name,
|
||||||
|
execution_profile: "direct",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (modelOptions.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -281,7 +313,7 @@ export function InputBox({
|
|||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
execution_profile: fallbackModel.execution_profile,
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
|
||||||
|
|
||||||
const selectedModel = useMemo(() => {
|
const selectedModel = useMemo(() => {
|
||||||
if (modelOptions.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
@@ -314,7 +346,10 @@ export function InputBox({
|
|||||||
model_name,
|
model_name,
|
||||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||||
reasoning_effort: context.reasoning_effort,
|
reasoning_effort: context.reasoning_effort,
|
||||||
execution_profile: model.execution_profile,
|
execution_profile:
|
||||||
|
context.execution_profile === "direct"
|
||||||
|
? "direct"
|
||||||
|
: model.execution_profile,
|
||||||
});
|
});
|
||||||
setModelDialogOpen(false);
|
setModelDialogOpen(false);
|
||||||
},
|
},
|
||||||
@@ -346,6 +381,16 @@ export function InputBox({
|
|||||||
[onContextChange, context, models, supportThinking],
|
[onContextChange, context, models, supportThinking],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDirectChatSelect = useCallback(() => {
|
||||||
|
const ordinaryModel =
|
||||||
|
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
model_name: ordinaryModel?.name ?? context.model_name,
|
||||||
|
execution_profile: "direct",
|
||||||
|
});
|
||||||
|
}, [context, models, onContextChange]);
|
||||||
|
|
||||||
const handleSingleRoundRagSelect = useCallback(() => {
|
const handleSingleRoundRagSelect = useCallback(() => {
|
||||||
const ragModel =
|
const ragModel =
|
||||||
extraModels.find((model) => model.name === context.model_name) ??
|
extraModels.find((model) => model.name === context.model_name) ??
|
||||||
@@ -749,7 +794,7 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
{!frontendRagActive && (
|
{!bypassAgentActive && (
|
||||||
<AddAttachmentsButton
|
<AddAttachmentsButton
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
@@ -758,7 +803,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && !hideContextTools && <Tooltip
|
{!bypassAgentActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
|
||||||
@@ -784,7 +829,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!bypassAgentActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
artifactDirectoryModeEnabled
|
artifactDirectoryModeEnabled
|
||||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||||
@@ -813,7 +858,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">文件</span>
|
<span className="text-xs font-normal">文件</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
@@ -839,7 +884,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
@@ -869,7 +914,7 @@ export function InputBox({
|
|||||||
<span className="text-xs font-normal">参考文献</span>
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>}
|
</Tooltip>}
|
||||||
{!frontendRagActive && <Tooltip
|
{!bypassAgentActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||||
@@ -909,7 +954,9 @@ export function InputBox({
|
|||||||
: "flash"
|
: "flash"
|
||||||
}
|
}
|
||||||
tooltipContent={
|
tooltipContent={
|
||||||
singleRoundRagActive
|
directChatActive
|
||||||
|
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
|
||||||
|
: singleRoundRagActive
|
||||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||||
: multiRoundRagActive
|
: multiRoundRagActive
|
||||||
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||||
@@ -921,7 +968,9 @@ export function InputBox({
|
|||||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
compact &&
|
||||||
(frontendRagActive
|
(directChatActive
|
||||||
|
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
|
||||||
|
: frontendRagActive
|
||||||
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||||
: context.mode === "thinking"
|
: context.mode === "thinking"
|
||||||
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
|
||||||
@@ -933,7 +982,9 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{frontendRagActive ? (
|
{directChatActive ? (
|
||||||
|
<GaugeIcon className="size-3" />
|
||||||
|
) : frontendRagActive ? (
|
||||||
<BookMarkedIcon className="size-3" />
|
<BookMarkedIcon className="size-3" />
|
||||||
) : context.mode === "flash" ? (
|
) : context.mode === "flash" ? (
|
||||||
<ZapIcon className="size-3" />
|
<ZapIcon className="size-3" />
|
||||||
@@ -948,10 +999,14 @@ export function InputBox({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-normal",
|
"text-xs font-normal",
|
||||||
context.mode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" && !directChatActive && !frontendRagActive
|
||||||
|
? "golden-text"
|
||||||
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{frontendRagActive
|
{directChatActive
|
||||||
|
? t.inputBox.turboMode
|
||||||
|
: frontendRagActive
|
||||||
? multiRoundRagActive
|
? multiRoundRagActive
|
||||||
? "多轮 RAG"
|
? "多轮 RAG"
|
||||||
: "单轮 RAG"
|
: "单轮 RAG"
|
||||||
@@ -969,9 +1024,39 @@ export function InputBox({
|
|||||||
{t.inputBox.mode}
|
{t.inputBox.mode}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
|
{turboModeVisible && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "flash"
|
directChatActive
|
||||||
|
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
|
||||||
|
: "text-muted-foreground/65",
|
||||||
|
)}
|
||||||
|
onSelect={handleDirectChatSelect}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-1 font-bold">
|
||||||
|
<GaugeIcon
|
||||||
|
className={cn(
|
||||||
|
"mr-2 size-4",
|
||||||
|
directChatActive && "text-sky-700 dark:text-sky-200",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t.inputBox.turboMode}
|
||||||
|
</div>
|
||||||
|
<div className="pl-7 text-xs">
|
||||||
|
{t.inputBox.turboModeDescription}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{directChatActive ? (
|
||||||
|
<CheckIcon className="ml-auto size-4" />
|
||||||
|
) : (
|
||||||
|
<div className="ml-auto size-4" />
|
||||||
|
)}
|
||||||
|
</PromptInputActionMenuItem>
|
||||||
|
)}
|
||||||
|
<PromptInputActionMenuItem
|
||||||
|
className={cn(
|
||||||
|
!bypassAgentActive && context.mode === "flash"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -982,7 +1067,7 @@ export function InputBox({
|
|||||||
<ZapIcon
|
<ZapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "flash" &&
|
!bypassAgentActive && context.mode === "flash" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -992,7 +1077,7 @@ export function InputBox({
|
|||||||
{t.inputBox.flashModeDescription}
|
{t.inputBox.flashModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "flash" ? (
|
{!bypassAgentActive && context.mode === "flash" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1001,7 +1086,7 @@ export function InputBox({
|
|||||||
{supportThinking && (
|
{supportThinking && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "thinking"
|
!bypassAgentActive && context.mode === "thinking"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1012,7 +1097,7 @@ export function InputBox({
|
|||||||
<LightbulbIcon
|
<LightbulbIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "thinking" &&
|
!bypassAgentActive && context.mode === "thinking" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1022,7 +1107,7 @@ export function InputBox({
|
|||||||
{t.inputBox.reasoningModeDescription}
|
{t.inputBox.reasoningModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "thinking" ? (
|
{!bypassAgentActive && context.mode === "thinking" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1031,7 +1116,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "pro"
|
!bypassAgentActive && context.mode === "pro"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1042,7 +1127,7 @@ export function InputBox({
|
|||||||
<GraduationCapIcon
|
<GraduationCapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive && context.mode === "pro" &&
|
!bypassAgentActive && context.mode === "pro" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1052,7 +1137,7 @@ export function InputBox({
|
|||||||
{t.inputBox.proModeDescription}
|
{t.inputBox.proModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "pro" ? (
|
{!bypassAgentActive && context.mode === "pro" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1060,7 +1145,7 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive && context.mode === "ultra"
|
!bypassAgentActive && context.mode === "ultra"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -1071,14 +1156,14 @@ export function InputBox({
|
|||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!frontendRagActive &&
|
!bypassAgentActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"text-[#dabb5e]",
|
"text-[#dabb5e]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
!frontendRagActive &&
|
!bypassAgentActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"golden-text",
|
"golden-text",
|
||||||
)}
|
)}
|
||||||
@@ -1090,7 +1175,7 @@ export function InputBox({
|
|||||||
{t.inputBox.ultraModeDescription}
|
{t.inputBox.ultraModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!frontendRagActive && context.mode === "ultra" ? (
|
{!bypassAgentActive && context.mode === "ultra" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1161,7 +1246,7 @@ export function InputBox({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||||
<div className="text-xs font-normal">
|
<div className="text-xs font-normal">
|
||||||
@@ -1279,7 +1364,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||||
{!frontendRagActive && agentSelector && (
|
{!bypassAgentActive && agentSelector && (
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
onAgentChange={agentSelector.onAgentChange}
|
onAgentChange={agentSelector.onAgentChange}
|
||||||
@@ -1287,8 +1372,8 @@ export function InputBox({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!frontendRagActive && knowledgeSelector}
|
{!bypassAgentActive && knowledgeSelector}
|
||||||
{!frontendRagActive && promptPrefix && (
|
{!bypassAgentActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
promptPrefix.on
|
promptPrefix.on
|
||||||
|
|||||||
@@ -6,13 +6,22 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||||
import {
|
import {
|
||||||
loadKnowledgeBaseProductVersion,
|
loadKnowledgeBaseProductVersion,
|
||||||
|
loadOrdinaryKnowledgeLanding,
|
||||||
|
saveOrdinaryKnowledgeLanding,
|
||||||
|
type OrdinaryKnowledgeLanding,
|
||||||
type KnowledgeBaseProductVersion,
|
type KnowledgeBaseProductVersion,
|
||||||
updateKnowledgeBaseProductVersion,
|
updateKnowledgeBaseProductVersion,
|
||||||
} from "@/core/system-settings/api";
|
} from "@/core/system-settings/api";
|
||||||
|
|
||||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||||
|
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||||
|
|
||||||
|
export function useOrdinaryKnowledgeLanding() {
|
||||||
|
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||||
|
}
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||||
|
|
||||||
@@ -25,6 +34,14 @@ export function KnowledgeProductNav() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const version = useKnowledgeBaseProductVersion();
|
const version = useKnowledgeBaseProductVersion();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
|
const saveLanding = useMutation({
|
||||||
|
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||||
|
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: updateKnowledgeBaseProductVersion,
|
mutationFn: updateKnowledgeBaseProductVersion,
|
||||||
@@ -36,15 +53,20 @@ export function KnowledgeProductNav() {
|
|||||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 border-b bg-card px-6 py-2">
|
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||||
<Button size="sm" variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||||
<BookOpen />普通知识库
|
<BookOpen />普通知识库
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||||
<Database />助手知识库
|
<Database />助手知识库
|
||||||
</Button>
|
</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
普通知识库默认页面
|
||||||
|
<Select disabled={landing.isLoading || saveLanding.isPending} value={landing.data ?? "wiki"} onValueChange={(value) => saveLanding.mutate(value as OrdinaryKnowledgeLanding)}>
|
||||||
|
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||||
|
</Select>
|
||||||
默认产品版本
|
默认产品版本
|
||||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||||
|
|||||||
@@ -205,21 +205,21 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
|||||||
/>
|
/>
|
||||||
<Database className="size-4 text-muted-foreground" />
|
<Database className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm">{target.target_name}</span>
|
<span className="text-sm">{target.target_name}</span>
|
||||||
<Badge variant="outline" className="ml-auto text-[10px]">WeKnora Wiki</Badge>
|
<Badge variant="outline" className="ml-auto text-[10px]">普通 Wiki 库</Badge>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
{(runtime.isLoading || knowledgeBases.isLoading) && <Loader2 className="m-4 size-5 animate-spin" />}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && knowledgeBases.error && (
|
||||||
<p className="p-3 text-xs text-destructive">
|
<p className="p-3 text-xs text-destructive">
|
||||||
读取 WeKnora 普通知识库失败:
|
读取普通知识库失败:
|
||||||
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
{knowledgeBases.error instanceof Error ? knowledgeBases.error.message : String(knowledgeBases.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
<p className="p-3 text-xs text-muted-foreground">
|
||||||
{runtime.data?.provider !== "weknora"
|
{runtime.data?.provider !== "weknora"
|
||||||
? "当前未配置 WeKnora 普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export const enUS: Translations = {
|
|||||||
mode: "Mode",
|
mode: "Mode",
|
||||||
flashMode: "Flash",
|
flashMode: "Flash",
|
||||||
flashModeDescription: "Fast and efficient, but may not be accurate",
|
flashModeDescription: "Fast and efficient, but may not be accurate",
|
||||||
|
turboMode: "Turbo",
|
||||||
|
turboModeDescription:
|
||||||
|
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
|
||||||
reasoningMode: "Reasoning",
|
reasoningMode: "Reasoning",
|
||||||
reasoningModeDescription:
|
reasoningModeDescription:
|
||||||
"Reasoning before action, balance between time and accuracy",
|
"Reasoning before action, balance between time and accuracy",
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface Translations {
|
|||||||
mode: string;
|
mode: string;
|
||||||
flashMode: string;
|
flashMode: string;
|
||||||
flashModeDescription: string;
|
flashModeDescription: string;
|
||||||
|
turboMode: string;
|
||||||
|
turboModeDescription: string;
|
||||||
reasoningMode: string;
|
reasoningMode: string;
|
||||||
reasoningModeDescription: string;
|
reasoningModeDescription: string;
|
||||||
proMode: string;
|
proMode: string;
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
|
|||||||
mode: "模式",
|
mode: "模式",
|
||||||
flashMode: "快速",
|
flashMode: "快速",
|
||||||
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
|
||||||
|
turboMode: "极速",
|
||||||
|
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
|
||||||
reasoningMode: "思考",
|
reasoningMode: "思考",
|
||||||
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
|
||||||
proMode: "专业问答",
|
proMode: "专业问答",
|
||||||
|
|||||||
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
|
|||||||
if (message.type !== "ai") {
|
if (message.type !== "ai") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (message.additional_kwargs) {
|
||||||
message.additional_kwargs &&
|
const reasoningContent = message.additional_kwargs.reasoning_content;
|
||||||
"reasoning_content" in message.additional_kwargs
|
if (typeof reasoningContent === "string") {
|
||||||
) {
|
return reasoningContent;
|
||||||
return message.additional_kwargs.reasoning_content as string | null;
|
}
|
||||||
|
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
|
||||||
|
const reasoning = message.additional_kwargs.reasoning;
|
||||||
|
if (typeof reasoning === "string") {
|
||||||
|
return reasoning;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
|
|||||||
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
if (typeof message.additional_kwargs?.reasoning_content === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typeof message.additional_kwargs?.reasoning === "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (Array.isArray(message.content)) {
|
if (Array.isArray(message.content)) {
|
||||||
const part = message.content[0];
|
const part = message.content[0];
|
||||||
// Compatible with the Anthropic gateway
|
// Compatible with the Anthropic gateway
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Model {
|
|||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsageSettings {
|
export interface TokenUsageSettings {
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ export interface PromptPrefixSettings {
|
|||||||
|
|
||||||
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
export type KnowledgeBaseProductVersion = "v1" | "v2";
|
||||||
|
|
||||||
|
export type OrdinaryKnowledgeLanding = "wiki" | "weknora";
|
||||||
|
|
||||||
|
export async function loadOrdinaryKnowledgeLanding(): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`);
|
||||||
|
if (!response.ok) throw new Error("无法读取普通知识库默认页面设置");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOrdinaryKnowledgeLanding(mode: OrdinaryKnowledgeLanding): Promise<OrdinaryKnowledgeLanding> {
|
||||||
|
const response = await apiFetch(`${getBackendBaseURL()}/api/system-settings/ordinary-knowledge-landing`, {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("保存失败,仅管理员可配置普通知识库默认页面");
|
||||||
|
return ((await response.json()) as { mode: OrdinaryKnowledgeLanding }).mode;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
export async function loadKnowledgeBaseProductVersion(): Promise<KnowledgeBaseProductVersion> {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
`${getBackendBaseURL()}/api/system-settings/knowledge-base-product-version`,
|
||||||
|
|||||||
@@ -1579,6 +1579,7 @@ export function useThreadStream({
|
|||||||
context: {
|
context: {
|
||||||
...mergedContext,
|
...mergedContext,
|
||||||
thinking_enabled: mergedContext.mode !== "flash",
|
thinking_enabled: mergedContext.mode !== "flash",
|
||||||
|
thinking_force_disabled: mergedContext.mode === "flash",
|
||||||
is_plan_mode:
|
is_plan_mode:
|
||||||
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
|
||||||
subagent_enabled: mergedContext.mode === "ultra",
|
subagent_enabled: mergedContext.mode === "ultra",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
thread_id: string;
|
thread_id: string;
|
||||||
model_name: string | undefined;
|
model_name: string | undefined;
|
||||||
thinking_enabled: boolean;
|
thinking_enabled: boolean;
|
||||||
|
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
|
||||||
|
thinking_force_disabled?: boolean;
|
||||||
is_plan_mode: boolean;
|
is_plan_mode: boolean;
|
||||||
subagent_enabled: boolean;
|
subagent_enabled: boolean;
|
||||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||||
@@ -29,8 +31,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
llmwiki_knowledge_base_ids?: string[];
|
llmwiki_knowledge_base_ids?: string[];
|
||||||
/** Assistant knowledge-base ids selected for this conversation. */
|
/** Assistant knowledge-base ids selected for this conversation. */
|
||||||
assistant_knowledge_base_ids?: string[];
|
assistant_knowledge_base_ids?: string[];
|
||||||
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
|
||||||
execution_profile?: "single_round_rag" | "multi_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
agent_name?: string;
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { readRuntimeFlag } from "@/env";
|
||||||
|
import { createClientId } from "@/lib/client-id";
|
||||||
|
|
||||||
|
import { textOfMessage } from "./utils";
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = "workspace.direct-chat.";
|
||||||
|
const MAX_HISTORY_MESSAGES = 20;
|
||||||
|
|
||||||
|
export function isDirectChatProfile(context: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof context === "object" &&
|
||||||
|
context !== null &&
|
||||||
|
(context as { execution_profile?: unknown }).execution_profile === "direct"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
|
||||||
|
export function isTurboModeVisible(): boolean {
|
||||||
|
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(threadId: string) {
|
||||||
|
return `${STORAGE_PREFIX}${threadId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredMessages(threadId: string): Message[] {
|
||||||
|
if (typeof window === "undefined" || !threadId) return [];
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||||
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||||
|
if (typeof window === "undefined" || !threadId) return;
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||||
|
} catch {
|
||||||
|
// Quota errors must never interrupt an already-running answer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return error instanceof DOMException
|
||||||
|
? error.name === "AbortError"
|
||||||
|
: error instanceof Error && error.name === "AbortError";
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyFromMessages(messages: Message[]): Array<{
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}> {
|
||||||
|
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.type !== "human" && message.type !== "ai") continue;
|
||||||
|
const content = textOfMessage(message)?.trim();
|
||||||
|
if (!content) continue;
|
||||||
|
history.push({
|
||||||
|
role: message.type === "human" ? "user" : "assistant",
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return history.slice(-MAX_HISTORY_MESSAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseBlocks(buffer: string): {
|
||||||
|
events: Array<{ event?: string; data: string }>;
|
||||||
|
rest: string;
|
||||||
|
} {
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
const rest = parts.pop() ?? "";
|
||||||
|
const events: Array<{ event?: string; data: string }> = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
let event: string | undefined;
|
||||||
|
const dataLines: string[] = [];
|
||||||
|
for (const line of part.split("\n")) {
|
||||||
|
if (line.startsWith(":")) continue;
|
||||||
|
if (line.startsWith("event:")) event = line.slice(6).trim();
|
||||||
|
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
events.push({ event, data: dataLines.join("\n") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorDetail(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"detail" in payload &&
|
||||||
|
typeof (payload as { detail: unknown }).detail === "string"
|
||||||
|
) {
|
||||||
|
return (payload as { detail: string }).detail;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to status text.
|
||||||
|
}
|
||||||
|
return `极速问答失败(${response.status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
|
||||||
|
* It never submits a LangGraph run or touches the lead-agent stack.
|
||||||
|
*/
|
||||||
|
export function useDirectChatMessages(threadId: string | undefined) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>(() =>
|
||||||
|
readStoredMessages(threadId ?? ""),
|
||||||
|
);
|
||||||
|
const messagesRef = useRef(messages);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readStoredMessages(threadId ?? "");
|
||||||
|
messagesRef.current = stored;
|
||||||
|
setMessages(stored);
|
||||||
|
setError(null);
|
||||||
|
}, [threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const updateMessages = useCallback(
|
||||||
|
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||||
|
setMessages((current) => {
|
||||||
|
const next = updater(current);
|
||||||
|
messagesRef.current = next;
|
||||||
|
writeStoredMessages(targetThreadId, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const submit = useCallback(
|
||||||
|
async ({
|
||||||
|
targetThreadId,
|
||||||
|
query,
|
||||||
|
modelName,
|
||||||
|
}: {
|
||||||
|
targetThreadId: string;
|
||||||
|
query: string;
|
||||||
|
modelName?: string;
|
||||||
|
}) => {
|
||||||
|
if (isLoading) return;
|
||||||
|
const question = query.trim();
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const humanId = createClientId("direct-human");
|
||||||
|
const assistantId = createClientId("direct-ai");
|
||||||
|
const clientTs = new Date().toISOString();
|
||||||
|
const history = historyFromMessages(messagesRef.current);
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: humanId,
|
||||||
|
type: "human",
|
||||||
|
content: [{ type: "text", text: question }],
|
||||||
|
additional_kwargs: { client_ts: clientTs, direct_chat: true },
|
||||||
|
};
|
||||||
|
const initialAssistant: Message = {
|
||||||
|
id: assistantId,
|
||||||
|
type: "ai",
|
||||||
|
content: "",
|
||||||
|
additional_kwargs: { direct_chat: true },
|
||||||
|
};
|
||||||
|
updateMessages(targetThreadId, (current) => [
|
||||||
|
...current,
|
||||||
|
userMessage,
|
||||||
|
initialAssistant,
|
||||||
|
]);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllerRef.current = controller;
|
||||||
|
let answer = "";
|
||||||
|
|
||||||
|
const refreshAssistant = (content: string) => {
|
||||||
|
updateMessages(targetThreadId, (current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === assistantId
|
||||||
|
? ({
|
||||||
|
...item,
|
||||||
|
content,
|
||||||
|
additional_kwargs: {
|
||||||
|
...item.additional_kwargs,
|
||||||
|
direct_chat: true,
|
||||||
|
},
|
||||||
|
} as Message)
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/direct-chat/stream`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_name: modelName,
|
||||||
|
messages: [...history, { role: "user", content: question }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorDetail(response));
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("极速问答未返回数据流。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parsed = parseSseBlocks(buffer);
|
||||||
|
buffer = parsed.rest;
|
||||||
|
for (const block of parsed.events) {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(block.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (block.event === "error") {
|
||||||
|
const detail =
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"detail" in payload &&
|
||||||
|
typeof (payload as { detail: unknown }).detail === "string"
|
||||||
|
? (payload as { detail: string }).detail
|
||||||
|
: "极速问答失败。";
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
block.event === "delta" &&
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"text" in payload &&
|
||||||
|
typeof (payload as { text: unknown }).text === "string"
|
||||||
|
) {
|
||||||
|
answer += (payload as { text: string }).text;
|
||||||
|
refreshAssistant(answer);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
block.event === "result" &&
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"answer" in payload &&
|
||||||
|
typeof (payload as { answer: unknown }).answer === "string"
|
||||||
|
) {
|
||||||
|
answer = (payload as { answer: string }).answer;
|
||||||
|
refreshAssistant(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!answer) {
|
||||||
|
throw new Error("模型未返回任何文本。");
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
if (!isAbortError(caught)) {
|
||||||
|
const message =
|
||||||
|
caught instanceof Error ? caught.message : "极速问答失败。";
|
||||||
|
setError(message);
|
||||||
|
updateMessages(targetThreadId, (current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === assistantId
|
||||||
|
? ({
|
||||||
|
...item,
|
||||||
|
content: answer || message,
|
||||||
|
additional_kwargs: {
|
||||||
|
...item.additional_kwargs,
|
||||||
|
direct_chat: true,
|
||||||
|
},
|
||||||
|
} as Message)
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (controllerRef.current === controller) controllerRef.current = null;
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isLoading, updateMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages, isLoading, error, submit, stop };
|
||||||
|
}
|
||||||
@@ -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 { useRouteBase } from "@/core/route-base";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
|
|||||||
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
|
||||||
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
|
||||||
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
|
||||||
|
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
@@ -560,9 +562,26 @@ export default function AgentChatPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directChat = useDirectChatMessages(threadId);
|
||||||
|
const directChatSelected =
|
||||||
|
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||||
|
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||||
const { displayThread } = useResearchWritingFeed(threadId, thread);
|
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
|
const displayThread = useMemo(() => {
|
||||||
|
const base = researchDisplayThread;
|
||||||
|
if (directChat.messages.length === 0 && !directChat.isLoading) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
const messages = [...base.messages, ...directChat.messages];
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
values: { ...base.values, messages },
|
||||||
|
messages,
|
||||||
|
isLoading: base.isLoading || directChat.isLoading,
|
||||||
|
} as typeof base;
|
||||||
|
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
|
||||||
|
|
||||||
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
|
||||||
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
|
||||||
@@ -743,6 +762,62 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
|
||||||
|
if (directChatSelected) {
|
||||||
|
if (message.files && message.files.length > 0) {
|
||||||
|
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasStartedConversation(true);
|
||||||
|
saveLastAgentId(agent_id);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
|
);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let targetThreadId = threadId;
|
||||||
|
if (isNewThread) {
|
||||||
|
const created = await getAPIClient().threads.create({
|
||||||
|
metadata: {
|
||||||
|
agent_id,
|
||||||
|
...(Object.keys(conversationMetadata).length > 0
|
||||||
|
? conversationMetadata
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
targetThreadId = created.thread_id;
|
||||||
|
knowledgeSelectionThread.current = targetThreadId;
|
||||||
|
activeThreadIdRef.current = targetThreadId;
|
||||||
|
setThreadId(targetThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
|
||||||
|
);
|
||||||
|
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||||
|
if (title) {
|
||||||
|
void getAPIClient().threads
|
||||||
|
.updateState(targetThreadId, { values: { title } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetThreadId) {
|
||||||
|
toast.error("无法创建会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await directChat.submit({
|
||||||
|
targetThreadId,
|
||||||
|
query: message.text,
|
||||||
|
modelName: settings.context.model_name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "极速问答失败。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
// Remember this agent so the new-chat landing screen pre-selects it next
|
// Remember this agent so the new-chat landing screen pre-selects it next
|
||||||
// time (covers both direct agent chats and the ChatPage handoff replay).
|
// time (covers both direct agent chats and the ChatPage handoff replay).
|
||||||
@@ -767,7 +842,7 @@ export default function AgentChatPage({
|
|||||||
...pageReportThemeContext,
|
...pageReportThemeContext,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
|
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
|
||||||
@@ -852,8 +927,12 @@ export default function AgentChatPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (directChat.isLoading) {
|
||||||
|
directChat.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [thread]);
|
}, [directChat, thread]);
|
||||||
|
|
||||||
const isLandingState = isNewThread && !hasStartedConversation;
|
const isLandingState = isNewThread && !hasStartedConversation;
|
||||||
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
|
||||||
@@ -867,7 +946,7 @@ export default function AgentChatPage({
|
|||||||
|
|
||||||
const status = thread.error
|
const status = thread.error
|
||||||
? "error"
|
? "error"
|
||||||
: thread.isLoading
|
: thread.isLoading || directChat.isLoading
|
||||||
? "streaming"
|
? "streaming"
|
||||||
: "ready";
|
: "ready";
|
||||||
|
|
||||||
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
|
|||||||
autoFocus
|
autoFocus
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
|
|||||||
pendingKnowledgeSelection.current === null &&
|
pendingKnowledgeSelection.current === null &&
|
||||||
pendingAssistantKnowledgeSelection.current === null
|
pendingAssistantKnowledgeSelection.current === null
|
||||||
}
|
}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
|
|||||||
assistantId={agent_id}
|
assistantId={agent_id}
|
||||||
status={status}
|
status={status}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
}
|
}
|
||||||
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
|
|||||||
assistantValue={selectedAssistantKnowledgeBaseIds}
|
assistantValue={selectedAssistantKnowledgeBaseIds}
|
||||||
onChange={changeKnowledgeSelection}
|
onChange={changeKnowledgeSelection}
|
||||||
onAssistantChange={changeAssistantKnowledgeSelection}
|
onAssistantChange={changeAssistantKnowledgeSelection}
|
||||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
|
||||||
readOnly
|
readOnly
|
||||||
label="知识空间"
|
label="知识空间"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BookOpenText,
|
BookOpenText,
|
||||||
@@ -67,6 +68,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
ReturnType<typeof searchAssistantWiki>
|
ReturnType<typeof searchAssistantWiki>
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const upload = useRef<HTMLInputElement>(null);
|
const upload = useRef<HTMLInputElement>(null);
|
||||||
|
const documentUpload = useRef<HTMLInputElement>(null);
|
||||||
const base = bases.data?.find((b) => b.id === baseId);
|
const base = bases.data?.find((b) => b.id === baseId);
|
||||||
const libraryPages = useMemo<LibraryWikiPage[]>(
|
const libraryPages = useMemo<LibraryWikiPage[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -159,8 +161,7 @@ export default function AssistantKnowledgePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
<h1 className="text-2xl font-semibold">助手知识库</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
浏览分类
|
选择或新建知识库,上传文档后自动生成分类 Wiki、关联知识和向量索引。可独立使用,也可导入 普通知识库数据包;各库知识汇入知识梳理总库。
|
||||||
Wiki、关联知识与来源。普通知识库向量化完成后自动沉淀到知识梳理。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -306,6 +307,12 @@ export default function AssistantKnowledgePage() {
|
|||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button size="sm" disabled={actions.uploadDocument.isPending} onClick={() => documentUpload.current?.click()}>
|
||||||
|
{actions.uploadDocument.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
|
||||||
|
上传文件生成 Wiki
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -314,10 +321,23 @@ export default function AssistantKnowledgePage() {
|
|||||||
onClick={() => upload.current?.click()}
|
onClick={() => upload.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="mr-2 size-4" />
|
<Upload className="mr-2 size-4" />
|
||||||
导入数据包
|
导入 普通知识库数据包
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<input ref={documentUpload} type="file" multiple accept=".md,.markdown,.txt,.csv,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp" className="hidden"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = "";
|
||||||
|
setTab("transfer");
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.size > 100 * 1024 * 1024) { toast.error(`${file.name} 超过 100 MB,请拆分后上传`); continue; }
|
||||||
|
try {
|
||||||
|
await actions.uploadDocument.mutateAsync({ file, assistantBaseId: baseId });
|
||||||
|
toast.success(`${file.name} 已上传,后台正在解析、生成 Wiki 并向量化`);
|
||||||
|
} catch (error) { toast.error(`${file.name}:${(error as Error).message}`); }
|
||||||
|
}
|
||||||
|
}} />
|
||||||
<input
|
<input
|
||||||
ref={upload}
|
ref={upload}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -441,7 +461,8 @@ export default function AssistantKnowledgePage() {
|
|||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
上次同步:{new Date(s.updated_at).toLocaleString()}
|
上次同步:{new Date(s.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
{isAdmin && (
|
{s.source_type === "file" && <p className="mt-2 text-xs text-muted-foreground">本地上传文档 · 处理进度及重试请查看导入记录</p>}
|
||||||
|
{isAdmin && s.source_type === "weknora" && (
|
||||||
<Button
|
<Button
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -469,8 +490,10 @@ export default function AssistantKnowledgePage() {
|
|||||||
>
|
>
|
||||||
<WikiPackageTransfers id={baseId} assistant />
|
<WikiPackageTransfers id={baseId} assistant />
|
||||||
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
<h2 className="text-base font-semibold">导入与同步记录</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">可直接上传文档,独立生成 Wiki 并进行向量检索,无需配置普通知识库服务。支持 PDF、Word、Excel、PowerPoint、Markdown、TXT、CSV,单文件最大 100 MB。生成失败时可配置语言模型或编码器后重试。</p>
|
||||||
{jobs.data?.map((j) => (
|
{jobs.data?.map((j) => (
|
||||||
<div key={j.id} className="rounded-xl border p-4">
|
<div key={j.id} className="rounded-xl border p-4">
|
||||||
|
<p className="mb-2 font-medium">{sources.data?.find((source) => source.id === j.source_id)?.source_name || (j.trigger === "local_file_upload" ? "上传文档" : "知识数据同步")}</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{{
|
{{
|
||||||
@@ -484,6 +507,9 @@ export default function AssistantKnowledgePage() {
|
|||||||
{new Date(j.created_at).toLocaleString()}
|
{new Date(j.created_at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{({ uploading: "接收文件", parsing: "解析文档", generating_wiki: "生成 Wiki", vectorizing: "正文向量化", importing: "保存知识并同步总库", queued: "等待处理", interrupted: "处理已中断" } as Record<string, string>)[j.phase] || j.phase}</p>
|
||||||
|
{j.counts?.parts_total > 0 && <p className="text-xs text-muted-foreground">文档分段:{j.counts.parts_done || 0} / {j.counts.parts_total}</p>}
|
||||||
|
{j.counts?.vectors_total > 0 && <p className="text-xs text-muted-foreground">向量化:{j.counts.vectors || 0} / {j.counts.vectors_total}</p>}
|
||||||
<p className="mt-3 text-sm">
|
<p className="mt-3 text-sm">
|
||||||
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
{j.counts?.wiki_pages || 0} Wiki ·{" "}
|
||||||
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
{j.counts?.vectors || 0} 向量 · {j.counts?.entities || 0}{" "}
|
||||||
@@ -491,9 +517,15 @@ export default function AssistantKnowledgePage() {
|
|||||||
</p>
|
</p>
|
||||||
{j.last_error_message && (
|
{j.last_error_message && (
|
||||||
<p className="mt-2 text-sm text-destructive">
|
<p className="mt-2 text-sm text-destructive">
|
||||||
{j.last_error_message}
|
{userFacingKnowledgeMessage(j.last_error_message)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{j.trigger === "local_file_upload" && j.status !== "completed" && (
|
||||||
|
<Button className="mt-2" size="sm" variant="outline" disabled={actions.retryDocument.isPending} onClick={async () => {
|
||||||
|
try { await actions.retryDocument.mutateAsync(j.id); toast.success("已继续处理,复用已有解析和向量结果"); }
|
||||||
|
catch (error) { toast.error((error as Error).message); }
|
||||||
|
}}>重试 / 恢复处理</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
|
|||||||
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
||||||
|
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
|
||||||
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
|
||||||
import { useLayoutMode } from "@/core/system-settings/hooks";
|
import { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
@@ -348,9 +349,12 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||||
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||||||
|
const directChat = useDirectChatMessages(threadId);
|
||||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||||
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||||
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||||
|
const directChatSelected =
|
||||||
|
isDirectChatProfile(settings.context) && isTurboModeVisible();
|
||||||
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
|
||||||
// 流式 stub),成功且真实产物落地后自动撤出。
|
// 流式 stub),成功且真实产物落地后自动撤出。
|
||||||
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
|
||||||
@@ -360,13 +364,15 @@ export default function ChatPage() {
|
|||||||
if (
|
if (
|
||||||
singleRoundRag.messages.length === 0 &&
|
singleRoundRag.messages.length === 0 &&
|
||||||
ragMessages.length === 0 &&
|
ragMessages.length === 0 &&
|
||||||
|
directChat.messages.length === 0 &&
|
||||||
!singleRoundRag.isLoading &&
|
!singleRoundRag.isLoading &&
|
||||||
!multiRoundRag.isLoading
|
!multiRoundRag.isLoading &&
|
||||||
|
!directChat.isLoading
|
||||||
) {
|
) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
const messages = mergeMessagesByTime(
|
const messages = mergeMessagesByTime(
|
||||||
[...base.messages, ...singleRoundRag.messages],
|
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
|
||||||
ragMessages,
|
ragMessages,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -374,9 +380,14 @@ export default function ChatPage() {
|
|||||||
values: { ...base.values, messages },
|
values: { ...base.values, messages },
|
||||||
messages,
|
messages,
|
||||||
isLoading:
|
isLoading:
|
||||||
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
base.isLoading ||
|
||||||
|
singleRoundRag.isLoading ||
|
||||||
|
multiRoundRag.isLoading ||
|
||||||
|
directChat.isLoading,
|
||||||
} as typeof base;
|
} as typeof base;
|
||||||
}, [
|
}, [
|
||||||
|
directChat.isLoading,
|
||||||
|
directChat.messages,
|
||||||
multiRoundRag.isLoading,
|
multiRoundRag.isLoading,
|
||||||
multiRoundRag.messages,
|
multiRoundRag.messages,
|
||||||
researchDisplayThread,
|
researchDisplayThread,
|
||||||
@@ -392,6 +403,52 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
|
if (directChatSelected) {
|
||||||
|
if (message.files && message.files.length > 0) {
|
||||||
|
toast.error("极速问答暂不支持附件,请移除附件后重试。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasStartedConversation(true);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("workspace:sandbox-resume-autoscroll"),
|
||||||
|
);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let targetThreadId = threadId;
|
||||||
|
if (isNewThread) {
|
||||||
|
const created = await getAPIClient(isMock).threads.create({});
|
||||||
|
targetThreadId = created.thread_id;
|
||||||
|
setThreadId(targetThreadId);
|
||||||
|
setIsNewThread(false);
|
||||||
|
history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
|
||||||
|
);
|
||||||
|
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
|
||||||
|
if (title) {
|
||||||
|
void getAPIClient(isMock).threads
|
||||||
|
.updateState(targetThreadId, { values: { title } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetThreadId) {
|
||||||
|
toast.error("无法创建会话。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await directChat.submit({
|
||||||
|
targetThreadId,
|
||||||
|
query: message.text,
|
||||||
|
modelName: settings.context.model_name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "极速问答失败。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (frontendRagSelected) {
|
if (frontendRagSelected) {
|
||||||
if (message.files && message.files.length > 0) {
|
if (message.files && message.files.length > 0) {
|
||||||
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||||||
@@ -560,6 +617,8 @@ export default function ChatPage() {
|
|||||||
multiRoundRag,
|
multiRoundRag,
|
||||||
multiRoundRagSelected,
|
multiRoundRagSelected,
|
||||||
frontendRagSelected,
|
frontendRagSelected,
|
||||||
|
directChat,
|
||||||
|
directChatSelected,
|
||||||
routeBase,
|
routeBase,
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
@@ -655,6 +714,10 @@ export default function ChatPage() {
|
|||||||
saveLastAgentId(agentId);
|
saveLastAgentId(agentId);
|
||||||
}, []);
|
}, []);
|
||||||
const handleStop = useCallback(async () => {
|
const handleStop = useCallback(async () => {
|
||||||
|
if (directChat.isLoading) {
|
||||||
|
directChat.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (singleRoundRag.isLoading) {
|
if (singleRoundRag.isLoading) {
|
||||||
singleRoundRag.stop();
|
singleRoundRag.stop();
|
||||||
return;
|
return;
|
||||||
@@ -664,7 +727,7 @@ export default function ChatPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [multiRoundRag, singleRoundRag, thread]);
|
}, [directChat, multiRoundRag, singleRoundRag, thread]);
|
||||||
|
|
||||||
const promptPrefixProp = useMemo(() => {
|
const promptPrefixProp = useMemo(() => {
|
||||||
if (!promptPrefixSettings?.enabled) return undefined;
|
if (!promptPrefixSettings?.enabled) return undefined;
|
||||||
@@ -914,7 +977,8 @@ export default function ChatPage() {
|
|||||||
agentHandoffPending ||
|
agentHandoffPending ||
|
||||||
knowledgeSelectionLoading ||
|
knowledgeSelectionLoading ||
|
||||||
singleRoundRag.isLoading ||
|
singleRoundRag.isLoading ||
|
||||||
multiRoundRag.isLoading
|
multiRoundRag.isLoading ||
|
||||||
|
directChat.isLoading
|
||||||
}
|
}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", context)
|
setSettings("context", context)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -211,7 +212,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
setTasks((current) => current.map((task) => task.id === taskId ? { ...task, active_report_job_id: null } : task));
|
||||||
}
|
}
|
||||||
if (event.type === "completed") toast.success("企业研究报告已完成");
|
if (event.type === "completed") toast.success("企业研究报告已完成");
|
||||||
if (event.type === "failed") toast.error(event.job.error_message || "报告生成失败");
|
if (event.type === "failed") toast.error(userFacingKnowledgeMessage(event.job.error_message || "报告生成失败"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
@@ -290,7 +291,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isWeKnoraReady) {
|
if (!isWeKnoraReady) {
|
||||||
toast.error("知识服务尚未启用,请先在系统中配置 WeKnora");
|
toast.error("知识服务尚未启用,请先在系统中配置知识服务");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,7 +443,7 @@ export default function EnterpriseResearchPage() {
|
|||||||
<section>
|
<section>
|
||||||
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
<Badge variant="secondary" className="mb-3"><Sparkles className="size-3" />阶段一:证据编排</Badge>
|
||||||
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
<h2 className="text-xl font-semibold tracking-tight">建立一个可追溯的研究任务</h2>
|
||||||
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 WeKnora 知识库,不会修改任何知识库或既有报告。</p>
|
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">页面独立于现有“深度研究”功能。任务、检索词与证据快照保存在 DeerFlow 后端;当前版本只使用已授权的 普通知识库,不会修改任何知识库或既有报告。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -609,12 +610,12 @@ function TaskOverview({
|
|||||||
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
<CardContent><ol className="space-y-4">{template.stages.map((stage, index) => {
|
||||||
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
const complete = task.status !== "collecting" && (index < 2 || task.sources.length > 0);
|
||||||
const current = task.status === "collecting" && index === 1;
|
const current = task.status === "collecting" && index === 1;
|
||||||
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用 WeKnora 检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
return <li key={stage} className="flex gap-3"><span className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold", complete ? "border-emerald-300 bg-emerald-50 text-emerald-700" : current ? "border-primary bg-primary/10 text-primary" : "text-muted-foreground")}>{complete ? <CheckCircle2 className="size-4" /> : index + 1}</span><div className="min-w-0 pt-0.5"><p className="text-sm font-medium">{stage}</p><p className="mt-1 text-xs leading-5 text-muted-foreground">{index === 0 ? "已固定研究主题、范围和知识库权限。" : index === 1 ? task.status === "collecting" ? "正在调用知识服务检索并去重证据。" : `已执行 ${task.queries.length} 个检索方向。` : index === 2 ? `${task.sources.length} 条去重后的资料可在右侧逐条核验。` : "研究计划会固定选用资料、章节结构和写作约束。"}</p></div></li>;
|
||||||
})}</ol></CardContent>
|
})}</ol></CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 WeKnora 知识库。</CardDescription></CardHeader>
|
<CardHeader className="gap-1"><CardTitle className="text-base">已执行检索词</CardTitle><CardDescription>检索词由模板和你的研究范围生成,结果仅来自当前账号可见的 普通知识库。</CardDescription></CardHeader>
|
||||||
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{task.warning}</div> : null}</CardContent>
|
<CardContent><div className="flex flex-wrap gap-2">{task.queries.map((query) => <Badge key={query} variant="outline" className="max-w-full whitespace-normal py-1 text-left"><Search className="size-3" />{query}</Badge>)}</div>{task.warning ? <div className="mt-4 flex gap-2 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"><AlertCircle className="mt-0.5 size-4 shrink-0" />{userFacingKnowledgeMessage(task.warning)}</div> : null}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{canPlan ? <Card>
|
{canPlan ? <Card>
|
||||||
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
<CardHeader className="gap-1"><CardTitle className="text-base">研究计划与选用资料</CardTitle><CardDescription>计划和资料选择会保存到当前任务;后续报告只能使用这里确认过的证据。</CardDescription></CardHeader>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function LlmWikiKnowledgePage({
|
|||||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.isError || runtime.data?.provider !== "weknora") {
|
if (runtime.isError || runtime.data?.provider !== "weknora" || !runtime.data?.api_base_url?.trim()) {
|
||||||
if (mode === "list" || mode === "detail") {
|
if (mode === "list" || mode === "detail") {
|
||||||
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
return <Navigate to="/page/workspace/knowledge/assistant" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
|
|
||||||
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
const BASE = () => `${getBackendBaseURL()}/api/assistant-knowledge`;
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
? (data as { detail: unknown }).detail
|
? (data as { detail: unknown }).detail
|
||||||
: `请求失败 (${response.status})`;
|
: `请求失败 (${response.status})`;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
typeof detail === "string" ? detail : JSON.stringify(detail),
|
userFacingKnowledgeMessage(typeof detail === "string" ? detail : JSON.stringify(detail)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
@@ -408,6 +409,17 @@ export async function uploadAssistantWeKnoraPackage(input: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadAssistantDocument(input: { file: File; assistantBaseId?: string | null }) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("file", input.file);
|
||||||
|
if (input.assistantBaseId) body.set("assistant_base_id", input.assistantBaseId);
|
||||||
|
return normalizeImportJob(await readJson<AssistantImportJob>(await apiFetch(`${BASE()}/imports/files`, { method: "POST", body })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryAssistantDocument(jobId: string) {
|
||||||
|
return readJson<AssistantImportJob>(await apiFetch(`${BASE()}/import-jobs/${encodeURIComponent(jobId)}/retry-file`, { method: "POST" }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
export async function deleteAssistantKnowledgeSource(sourceId: string) {
|
||||||
const response = await apiFetch(
|
const response = await apiFetch(
|
||||||
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
`${BASE()}/sources/${encodeURIComponent(sourceId)}`,
|
||||||
@@ -490,6 +502,8 @@ export function useAssistantKnowledgeActions() {
|
|||||||
mutationFn: uploadAssistantWeKnoraPackage,
|
mutationFn: uploadAssistantWeKnoraPackage,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
}),
|
}),
|
||||||
|
uploadDocument: useMutation({ mutationFn: uploadAssistantDocument, onSuccess: invalidate }),
|
||||||
|
retryDocument: useMutation({ mutationFn: retryAssistantDocument, onSuccess: invalidate }),
|
||||||
reimportSource: useMutation({
|
reimportSource: useMutation({
|
||||||
mutationFn: reimportAssistantKnowledgeSource,
|
mutationFn: reimportAssistantKnowledgeSource,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ async function readJson<T>(response: Response, context: string): Promise<T> {
|
|||||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
? String((payload as { detail: unknown }).detail)
|
? String((payload as { detail: unknown }).detail)
|
||||||
: `${context}失败(${response.status})`;
|
: `${context}失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
@@ -249,6 +250,6 @@ export async function deleteEnterpriseResearchTask(taskId: string): Promise<void
|
|||||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
? String((payload as { detail: unknown }).detail)
|
? String((payload as { detail: unknown }).detail)
|
||||||
: `删除研究任务失败(${response.status})`;
|
: `删除研究任务失败(${response.status})`;
|
||||||
throw new Error(detail);
|
throw new Error(userFacingKnowledgeMessage(detail));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getCsrfToken } from "@/core/api/csrf";
|
|||||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
|
|
||||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||||
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
const PUBLIC_API = () => `${getBackendBaseURL()}/api/public/llmwiki`;
|
||||||
@@ -366,10 +367,6 @@ export interface LlmWikiWikiPageUpdate {
|
|||||||
wiki_path?: string;
|
wiki_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function userFacingKnowledgeMessage(value: string): string {
|
|
||||||
return value.replace(/weknora/gi, "知识服务");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const payload = await response.json().catch(() => null as unknown);
|
const payload = await response.json().catch(() => null as unknown);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "@/core/api/fetch-client";
|
import { apiFetch } from "@/core/api/fetch-client";
|
||||||
@@ -102,7 +103,7 @@ async function readJson<T>(response: Response): Promise<T> {
|
|||||||
data && typeof data === "object" && "detail" in data
|
data && typeof data === "object" && "detail" in data
|
||||||
? JSON.stringify((data as { detail: unknown }).detail)
|
? JSON.stringify((data as { detail: unknown }).detail)
|
||||||
: `请求失败 (${response.status})`;
|
: `请求失败 (${response.status})`;
|
||||||
throw new Error(detail.replace(/^"|"$/g, ""));
|
throw new Error(userFacingKnowledgeMessage(detail.replace(/^"|"$/g, "")));
|
||||||
}
|
}
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-10
@@ -17,6 +17,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useOrdinaryKnowledgeLanding } from "@/components/workspace/knowledge-product-nav";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -139,6 +140,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
const [frameError, setFrameError] = useState<string | null>(null);
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||||
const runtime = useLlmWikiRuntime();
|
const runtime = useLlmWikiRuntime();
|
||||||
|
const landing = useOrdinaryKnowledgeLanding();
|
||||||
const details = useLlmWikiKnowledgeBase(
|
const details = useLlmWikiKnowledgeBase(
|
||||||
knowledgeBaseId || null,
|
knowledgeBaseId || null,
|
||||||
!!knowledgeBaseId,
|
!!knowledgeBaseId,
|
||||||
@@ -165,6 +167,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
searchParams.get("sourceChunkId") || searchParams.get("chunkId") || "";
|
||||||
const documentId = searchParams.get("documentId") || "";
|
const documentId = searchParams.get("documentId") || "";
|
||||||
const wikiSlug = searchParams.get("wikiSlug") || "";
|
const wikiSlug = searchParams.get("wikiSlug") || "";
|
||||||
|
const defaultEntry = searchParams.get("view") !== "weknora" && !requestedTab && !wikiSlug && !sourceChunkId && !documentId;
|
||||||
|
const showNativeIndex = defaultEntry && (landing.data ?? "wiki") === "wiki" && !!detailsWikiEnabled;
|
||||||
const nativeWikiPageUrl =
|
const nativeWikiPageUrl =
|
||||||
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
knowledgeBaseId && wikiSlug && preferredTab === "wiki"
|
||||||
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
? buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)
|
||||||
@@ -175,7 +179,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
setFrameSrc("");
|
setFrameSrc("");
|
||||||
setFrameError(null);
|
setFrameError(null);
|
||||||
if (
|
if (
|
||||||
nativeWikiPageUrl ||
|
nativeWikiPageUrl || landing.isLoading || showNativeIndex ||
|
||||||
!knowledgeBaseId ||
|
!knowledgeBaseId ||
|
||||||
runtime.data?.provider !== "weknora" ||
|
runtime.data?.provider !== "weknora" ||
|
||||||
!detailsReady
|
!detailsReady
|
||||||
@@ -225,6 +229,8 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
documentId,
|
documentId,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
nativeWikiPageUrl,
|
nativeWikiPageUrl,
|
||||||
|
landing.isLoading,
|
||||||
|
showNativeIndex,
|
||||||
preferredTab,
|
preferredTab,
|
||||||
runtime.data?.provider,
|
runtime.data?.provider,
|
||||||
sourceChunkId,
|
sourceChunkId,
|
||||||
@@ -253,7 +259,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
return <Navigate to={nativeWikiPageUrl} replace />;
|
return <Navigate to={nativeWikiPageUrl} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (runtime.isLoading || details.isLoading) {
|
if (runtime.isLoading || details.isLoading || landing.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
@@ -262,6 +268,10 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showNativeIndex) {
|
||||||
|
return <Navigate to={`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.data?.provider !== "weknora")
|
if (runtime.data?.provider !== "weknora")
|
||||||
return <Navigate to={LIST_PATH} replace />;
|
return <Navigate to={LIST_PATH} replace />;
|
||||||
|
|
||||||
@@ -313,7 +323,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
{canManageWiki && (
|
{detailsWikiEnabled && (
|
||||||
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
<div className="flex min-h-12 shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{canEditCurrentWikiArticle && (
|
{canEditCurrentWikiArticle && (
|
||||||
@@ -334,6 +344,7 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view`)}>查看 Wiki 索引</Button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -348,18 +359,14 @@ export function WeKnoraKnowledgeDetailPage() {
|
|||||||
数据包任务
|
数据包任务
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{canManageWiki && <Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() => navigate(`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`)}
|
||||||
navigate(
|
|
||||||
`/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FileText className="mr-2 size-4" />
|
<FileText className="mr-2 size-4" />
|
||||||
Wiki管理
|
Wiki管理
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
@@ -180,7 +181,7 @@ export default function WeKnoraPackageTransfersPage() {
|
|||||||
</div>
|
</div>
|
||||||
{job.error && (
|
{job.error && (
|
||||||
<p className="mt-2 text-sm text-destructive">
|
<p className="mt-2 text-sm text-destructive">
|
||||||
{job.error}
|
{userFacingKnowledgeMessage(job.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-10
@@ -153,11 +153,11 @@ export function WeKnoraWikiPageView({
|
|||||||
const pages = publicEmbed ? publicPages : privatePages;
|
const pages = publicEmbed ? publicPages : privatePages;
|
||||||
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
const remotePagesList = useMemo(() => pages.data?.pages || [], [pages.data?.pages]);
|
||||||
const explicitIndexPage = useMemo(
|
const explicitIndexPage = useMemo(
|
||||||
() => (publicEmbed ? remotePagesList.find(isWikiIndexPage) : undefined),
|
() => remotePagesList.find(isWikiIndexPage),
|
||||||
[publicEmbed, remotePagesList],
|
[publicEmbed, remotePagesList],
|
||||||
);
|
);
|
||||||
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
const generatedIndexPage = useMemo<LlmWikiWikiPage | undefined>(
|
||||||
() => publicEmbed && remotePagesList.length > 0 && !explicitIndexPage
|
() => remotePagesList.length > 0 && !explicitIndexPage
|
||||||
? {
|
? {
|
||||||
id: "__generated_public_index__",
|
id: "__generated_public_index__",
|
||||||
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
slug: GENERATED_PUBLIC_INDEX_SLUG,
|
||||||
@@ -181,14 +181,14 @@ export function WeKnoraWikiPageView({
|
|||||||
[generatedIndexPage, remotePagesList],
|
[generatedIndexPage, remotePagesList],
|
||||||
);
|
);
|
||||||
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
const landingPage = useMemo(() => selectDefaultWikiPage(pagesList), [pagesList]);
|
||||||
const wikiSlug = requestedWikiSlug || (publicEmbed ? landingPage?.slug || "" : "");
|
const wikiSlug = requestedWikiSlug || landingPage?.slug || "";
|
||||||
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
const generatedIndexActive = generatedIndexPage?.slug === wikiSlug;
|
||||||
const generatedIndexRequested =
|
const generatedIndexRequested =
|
||||||
publicEmbed && wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
wikiSlug === GENERATED_PUBLIC_INDEX_SLUG && !explicitIndexPage;
|
||||||
const privatePage = useLlmWikiWikiPage(
|
const privatePage = useLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
wikiSlug || null,
|
wikiSlug || null,
|
||||||
!!noteId && !!wikiSlug && !publicEmbed,
|
!!noteId && !!wikiSlug && !publicEmbed && !generatedIndexActive && !generatedIndexRequested,
|
||||||
);
|
);
|
||||||
const publicPage = usePublicLlmWikiWikiPage(
|
const publicPage = usePublicLlmWikiWikiPage(
|
||||||
noteId || null,
|
noteId || null,
|
||||||
@@ -377,15 +377,15 @@ export function WeKnoraWikiPageView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!wikiSlug) {
|
if (!wikiSlug) {
|
||||||
if (publicEmbed && pages.isLoading) {
|
if (pages.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||||
正在加载公开 Wiki
|
正在加载 Wiki 索引
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (publicEmbed && pages.error) {
|
if (pages.error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
@@ -405,9 +405,9 @@ export function WeKnoraWikiPageView({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||||
{publicEmbed ? "该知识库暂无可展示的 Wiki 页面" : "当前链接缺少 Wiki 页面标识"}
|
该知识库暂无可展示的 Wiki 页面
|
||||||
{!publicEmbed && (
|
{!publicEmbed && (
|
||||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button>
|
<><Button variant="outline" onClick={() => navigate(LIST_PATH)}>返回知识库</Button><Button onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>进入知识库管理,导入资料</Button></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -492,6 +492,7 @@ export function WeKnoraWikiPageView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate(`${LIST_PATH}/${encodeURIComponent(noteId)}?view=weknora`)}>知识库管理</Button>
|
||||||
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
{globalBase && isDeposited && <Button variant="outline" size="sm" onClick={() => navigate(`/page/workspace/knowledge/assistant?${new URLSearchParams({ base_id: globalBase.id, wikiSlug: pageData.slug })}`)}>在知识梳理中查看</Button>}
|
||||||
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
<Button variant="outline" size="sm" onClick={() => void download("md")} disabled={downloading !== null}>
|
||||||
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
{downloading === "md" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { userFacingKnowledgeMessage } from "@/lib/knowledge-message";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Download, Loader2, Upload } from "lucide-react";
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
@@ -110,7 +111,7 @@ export function WikiPackageTransfers({
|
|||||||
{job.file_size_bytes ? (
|
{job.file_size_bytes ? (
|
||||||
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
<span>{(job.file_size_bytes / 1024 / 1024).toFixed(2)} MB</span>
|
||||||
) : null}
|
) : null}
|
||||||
{job.error && <span className="text-destructive">{job.error}</span>}
|
{job.error && <span className="text-destructive">{userFacingKnowledgeMessage(job.error)}</span>}
|
||||||
{assistant &&
|
{assistant &&
|
||||||
job.status === "completed" &&
|
job.status === "completed" &&
|
||||||
job.file_exists &&
|
job.file_exists &&
|
||||||
|
|||||||
@@ -1014,9 +1014,11 @@ scheduler runs once per deployment. Per run:
|
|||||||
|
|
||||||
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
|
||||||
|
|
||||||
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
|
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
|
||||||
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
|
- Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
|
||||||
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
|
- Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
|
||||||
|
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
|
||||||
|
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
|
||||||
|
|
||||||
### IM Channels System (`app/channels/`)
|
### IM Channels System (`app/channels/`)
|
||||||
|
|
||||||
@@ -1762,9 +1764,12 @@ gone.
|
|||||||
|
|
||||||
Assistant knowledge has one global full-library base (`知识梳理总库`) and any
|
Assistant knowledge has one global full-library base (`知识梳理总库`) and any
|
||||||
number of admin-created custom bases. Ordinary users have read/search access and
|
number of admin-created custom bases. Ordinary users have read/search access and
|
||||||
administrators own all mutations. Assistant bases only accept WeKnora export
|
administrators own all mutations. Assistant bases accept local documents independently
|
||||||
packages or imports triggered from a WeKnora mapping. Do not add direct skill
|
of WeKnora via `/api/assistant-knowledge/imports/files`, as well as WeKnora export
|
||||||
imports or arbitrary local-file imports to assistant knowledge. Importing into a
|
packages or imports triggered from a WeKnora mapping. Local documents use the
|
||||||
|
Gateway `assistant_file_ingest` pipeline: persisted original files, chunked LLM Wiki
|
||||||
|
extraction, cached body embeddings, and import-job status/retry. Do not add direct skill
|
||||||
|
imports. Importing into a
|
||||||
custom assistant base must also mirror the package into the global base for
|
custom assistant base must also mirror the package into the global base for
|
||||||
cross-source entity alignment. Admins may rename any assistant base and delete
|
cross-source entity alignment. Admins may rename any assistant base and delete
|
||||||
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
custom bases. Deletion removes the custom base's Wiki pages, vectors, entities,
|
||||||
@@ -1773,6 +1778,8 @@ deletable because it owns full-library aggregation.
|
|||||||
|
|
||||||
### Wiki package and local encoder invariants (2026-09)
|
### Wiki package and local encoder invariants (2026-09)
|
||||||
|
|
||||||
|
Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_landing` (`wiki` default / `weknora`), with admin-only PUT `/api/system-settings/ordinary-knowledge-landing`. Explicit document/graph/article links bypass this default. Wiki reading and the embedded WeKnora view provide reciprocal navigation; `?view=weknora` explicitly selects the iframe and must not loop back to the default Wiki route.
|
||||||
|
|
||||||
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
- `knowledge_transfer.sync_to_global` is called after successful explicit local
|
||||||
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
Wiki vectorization; it skips conversation/system mappings. Do not enable a new
|
||||||
watcher or automatic remote-write scheduler as a side effect.
|
watcher or automatic remote-write scheduler as a side effect.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# DeerFlow Backend
|
# DeerFlow Backend
|
||||||
|
|
||||||
|
普通知识库默认详情页面:管理员可在知识库顶部选择“本系统 Wiki 索引”或“嵌入 WeKnora”,对应 `ordinary_knowledge_landing`(默认 `wiki`)。Wiki 库首次进入默认展示本系统索引,右上角“打开 WeKnora”切换到内嵌管理页;内嵌页可返回索引。非 Wiki 库仍进入 WeKnora 文档页。设置通过 `/api/system-settings/ordinary-knowledge-landing` 保存,仅管理员可修改;指定文章、文档、图谱的直达链接优先于默认设置。
|
||||||
|
|
||||||
|
助手知识库可独立于 WeKnora 使用:管理员进入助手知识库,选择或创建库后点击“上传文件生成 Wiki”。支持 Markdown/TXT/CSV、PDF、Word、Excel、PowerPoint(单文件 100 MB,解析正文上限 400 万字符)。文件先写入运行目录 `assistant-knowledge/files/{job_id}`,后台分段调用已配置的语言模型生成分类 Wiki、实体关系,再通过 DeerFlow 编码器对正文分段向量化,保存到助手库并汇入知识梳理总库。无需配置 WeKnora URL;配置后仍可直接上传,也保留 WeKnora 数据包导入。文件处理沿用管理员写入权限,所有用户可以浏览和搜索。
|
||||||
|
|
||||||
|
导入记录显示解析、Wiki 生成和向量化阶段;失败或服务中断后可“重试 / 恢复处理”,复用已落盘的 Wiki 和相同模型指纹的向量。离线编码器须预先放置模型并在管理员设置中配置;模型或编码器不可用会明确失败,不把摘要冒充 Wiki 或虚构向量。扫描件是否可解析取决于部署的文档解析/OCR 能力。
|
||||||
|
|
||||||
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent memory, and extensible tool integration. The backend enables AI agents to execute code, browse the web, manage files, delegate tasks to subagents, and retain context across conversations - all in isolated, per-thread environments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.gateway.routers import (
|
|||||||
compact_menu_overrides,
|
compact_menu_overrides,
|
||||||
dashboard_sessions,
|
dashboard_sessions,
|
||||||
deep_research,
|
deep_research,
|
||||||
|
direct_chat,
|
||||||
external_llmwiki,
|
external_llmwiki,
|
||||||
feedback,
|
feedback,
|
||||||
fixed_questions,
|
fixed_questions,
|
||||||
@@ -1803,6 +1804,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
|
||||||
app.include_router(open_chat.router)
|
app.include_router(open_chat.router)
|
||||||
|
|
||||||
|
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
|
||||||
|
app.include_router(direct_chat.router)
|
||||||
|
|
||||||
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
|
||||||
app.include_router(open_3qfx.router)
|
app.include_router(open_3qfx.router)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Local document → Wiki → vectors, independent of the WeKnora service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deerflow.config.runtime_paths import runtime_home
|
||||||
|
from deerflow.knowledge.llm_extractor import extract_document_llm_multi
|
||||||
|
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = CONVERTIBLE_EXTENSIONS | {".md", ".markdown", ".txt", ".csv"}
|
||||||
|
MAX_FILE_BYTES = 100 * 1024 * 1024
|
||||||
|
MAX_TEXT_CHARS = 4_000_000
|
||||||
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
_slots = asyncio.Semaphore(2)
|
||||||
|
_queue_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def file_job_dir(job_id: str) -> Path:
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
return runtime_home() / "assistant-knowledge" / "files" / str(UUID(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(path: Path, value: Any) -> None:
|
||||||
|
temporary = path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> Any:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def split_text(text: str, size: int):
|
||||||
|
"""Bound every extraction/embedding input without truncating the document."""
|
||||||
|
for offset in range(0, len(text), size):
|
||||||
|
yield text[offset : offset + size]
|
||||||
|
|
||||||
|
|
||||||
|
def notes_package(notes: list[dict], source: dict) -> dict:
|
||||||
|
from deerflow.persistence.assistant_knowledge.store import _entity_alignment_key
|
||||||
|
|
||||||
|
merged: dict[str, dict] = {}
|
||||||
|
for raw in notes:
|
||||||
|
key = _entity_alignment_key(raw["title"])
|
||||||
|
if key not in merged:
|
||||||
|
merged[key] = dict(raw)
|
||||||
|
continue
|
||||||
|
note = merged[key]
|
||||||
|
note["content_md"] = "\n\n".join(dict.fromkeys([note["content_md"], raw["content_md"]]))
|
||||||
|
for field in ("tags", "entities", "relations"):
|
||||||
|
note[field] = [*note.get(field, []), *raw.get(field, [])]
|
||||||
|
note["tags"] = list(dict.fromkeys(note["tags"]))
|
||||||
|
pages, entities, relations = [], [], []
|
||||||
|
for index, note in enumerate(merged.values()):
|
||||||
|
slug = f"files/{source['digest']}/{index}"
|
||||||
|
pages.append(
|
||||||
|
{
|
||||||
|
"slug": slug,
|
||||||
|
"title": note["title"],
|
||||||
|
"content": note["content_md"],
|
||||||
|
"page_type": "article",
|
||||||
|
"tags": note.get("tags", []),
|
||||||
|
"category_path": [note.get("category") or "知识文档"],
|
||||||
|
"source_refs": [{"type": "file", "title": source["filename"]}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for entity in note.get("entities", []):
|
||||||
|
entities.append({"id": entity["name"], "name": entity["name"], "entity_type": entity.get("type", "concept"), "aliases": entity.get("aliases", []), "source_wiki_slug": slug})
|
||||||
|
for relation in note.get("relations", []):
|
||||||
|
relations.append({"source": relation["from"], "target": relation["to"], "predicate": relation.get("type", "related_to"), "source_wiki_slug": slug})
|
||||||
|
# Persist real sibling links, understood by the shared Wiki reader.
|
||||||
|
for index, page in enumerate(pages):
|
||||||
|
others = [p for p in pages[max(0, index - 4) : index + 5] if p["slug"] != page["slug"]]
|
||||||
|
page["out_links"] = [p["slug"] for p in others]
|
||||||
|
page["in_links"] = list(page["out_links"])
|
||||||
|
if others:
|
||||||
|
page["content"] += "\n\n## 相关知识\n" + "\n".join(f"- [[{p['slug']}|{p['title']}]]" for p in others)
|
||||||
|
return {"source": source, "wiki_pages": pages, "entities": entities, "relations": relations, "vectors": []}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_file_job(app, job_id: str) -> None:
|
||||||
|
store = app.state.assistant_knowledge_store
|
||||||
|
root = file_job_dir(job_id)
|
||||||
|
try:
|
||||||
|
async with _slots:
|
||||||
|
manifest = await asyncio.to_thread(read_json, root / "manifest.json")
|
||||||
|
await store.update_import_job(job_id, status="running", phase="parsing")
|
||||||
|
package_file = root / "wiki.json"
|
||||||
|
if package_file.exists():
|
||||||
|
package = await asyncio.to_thread(read_json, package_file)
|
||||||
|
else:
|
||||||
|
text_file = root / "parsed.md"
|
||||||
|
if not text_file.exists():
|
||||||
|
original = root / manifest["stored_name"]
|
||||||
|
if original.suffix in CONVERTIBLE_EXTENSIONS:
|
||||||
|
converted = await asyncio.to_thread(lambda: asyncio.run(convert_file_to_markdown(original)))
|
||||||
|
if converted is None:
|
||||||
|
raise ValueError("无法解析文档,请检查文件是否损坏或是否需要 OCR")
|
||||||
|
text = await asyncio.to_thread(converted.read_text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
raw = await asyncio.to_thread(original.read_bytes)
|
||||||
|
try:
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = raw.decode("gb18030")
|
||||||
|
if not text.strip() or len(text) > MAX_TEXT_CHARS:
|
||||||
|
raise ValueError("文件没有可提取文本,或解析正文超过 400 万字符,请拆分文件")
|
||||||
|
await asyncio.to_thread(text_file.write_text, text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
text = await asyncio.to_thread(text_file.read_text, encoding="utf-8")
|
||||||
|
notes = []
|
||||||
|
parts = list(split_text(text, 10000))
|
||||||
|
for index, part in enumerate(parts):
|
||||||
|
await store.update_import_job(job_id, phase="generating_wiki", counts={"parts_total": len(parts), "parts_done": index})
|
||||||
|
cache = root / f"notes-{index}.json"
|
||||||
|
if cache.exists():
|
||||||
|
drafts = await asyncio.to_thread(read_json, cache)
|
||||||
|
else:
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
extracted = await asyncio.wait_for(
|
||||||
|
extract_document_llm_multi(
|
||||||
|
part,
|
||||||
|
doc_id=f"{job_id}:{index}",
|
||||||
|
doc_title=manifest["filename"],
|
||||||
|
max_input_chars=12000,
|
||||||
|
max_notes=6,
|
||||||
|
),
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
if not extracted:
|
||||||
|
raise ValueError("Wiki 生成失败,请在设置中检查可用的语言模型,然后重试")
|
||||||
|
drafts = [asdict(note) for note in extracted]
|
||||||
|
await asyncio.to_thread(save_json, cache, drafts)
|
||||||
|
notes.extend(drafts)
|
||||||
|
package = notes_package(notes, {"type": "file", "filename": manifest["filename"], "digest": manifest["digest"]})
|
||||||
|
await asyncio.to_thread(save_json, package_file, package)
|
||||||
|
embedding = getattr(app.state, "llmwiki_embedding", None)
|
||||||
|
if embedding is None:
|
||||||
|
raise ValueError("Wiki 已生成;请管理员在设置中配置本地编码器或编码服务,然后重试向量化")
|
||||||
|
vectors = []
|
||||||
|
sections = [(p, i, section) for p in package["wiki_pages"] for i, section in enumerate(split_text(p["content"], 1600))]
|
||||||
|
for index, (page, section_index, content) in enumerate(sections):
|
||||||
|
await store.update_import_job(job_id, phase="vectorizing", counts={"wiki_pages": len(package["wiki_pages"]), "vectors_total": len(sections), "vectors": index})
|
||||||
|
digest = hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
cache = root / f"vector-{digest}.json"
|
||||||
|
record = await asyncio.to_thread(read_json, cache) if cache.exists() else None
|
||||||
|
if record is None or record["embedding_fingerprint"] != embedding.fingerprint:
|
||||||
|
values = await embedding.embed_texts([content])
|
||||||
|
record = {"vector": values[0], "embedding_model": embedding.config.model, "embedding_fingerprint": embedding.fingerprint, "content_hash": digest}
|
||||||
|
await asyncio.to_thread(save_json, cache, record)
|
||||||
|
vectors.append({**record, "wiki_slug": page["slug"], "section_index": section_index, "section_content": content})
|
||||||
|
package["vectors"] = vectors
|
||||||
|
await store.update_import_job(job_id, phase="importing")
|
||||||
|
await store.import_package_for_job(job_id, package=package, created_by=manifest["actor"])
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await store.update_import_job(job_id, status="failed", phase="interrupted", error="服务停止,已保留处理进度,可重试", completed=True)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
await store.update_import_job(job_id, status="failed", phase="failed", error=str(exc) or type(exc).__name__, completed=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_file_job(app, job_id: str) -> bool:
|
||||||
|
if job_id in _tasks:
|
||||||
|
return False
|
||||||
|
task = asyncio.create_task(run_file_job(app, job_id))
|
||||||
|
_tasks[job_id] = task
|
||||||
|
task.add_done_callback(lambda _: _tasks.pop(job_id, None))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_file_jobs() -> None:
|
||||||
|
tasks = list(_tasks.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_file_job(app, job_id: str) -> bool:
|
||||||
|
async with _queue_lock:
|
||||||
|
if job_id in _tasks:
|
||||||
|
return False
|
||||||
|
await app.state.assistant_knowledge_store.update_import_job(job_id, status="queued", phase="queued")
|
||||||
|
return start_file_job(app, job_id)
|
||||||
@@ -701,6 +701,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
from app.gateway.assistant_file_ingest import stop_file_jobs
|
||||||
|
|
||||||
|
await stop_file_jobs()
|
||||||
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
|
||||||
|
|
||||||
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ def _list_export_manifests(mapping_id: str, *, limit: int) -> list[dict[str, Any
|
|||||||
|
|
||||||
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
def _delete_base_package_files(base_id: str, import_job_ids: list[str]) -> None:
|
||||||
"""Delete package artifacts owned by one assistant base only."""
|
"""Delete package artifacts owned by one assistant base only."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from app.gateway.assistant_file_ingest import file_job_dir
|
||||||
|
|
||||||
|
for job_id in import_job_ids:
|
||||||
|
# UUID validation confines cleanup to exactly one job directory.
|
||||||
|
try:
|
||||||
|
directory = file_job_dir(job_id)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if directory.is_dir() and not directory.is_symlink():
|
||||||
|
shutil.rmtree(directory)
|
||||||
root = _package_root()
|
root = _package_root()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return
|
return
|
||||||
@@ -448,10 +460,71 @@ async def import_skills(request: Request, _body: SkillImportRequest) -> dict[str
|
|||||||
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
|
raise HTTPException(status_code=410, detail="不支持技能直接归纳到助手知识库,请先归纳到 WeKnora 普通知识库后再导入助手知识库")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/imports/files", status_code=410)
|
@router.post("/imports/files", status_code=202)
|
||||||
async def import_files(request: Request) -> dict[str, Any]:
|
async def import_files(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
assistant_base_id: str | None = Form(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.gateway.assistant_file_ingest import ALLOWED_EXTENSIONS, MAX_FILE_BYTES, file_job_dir, save_json, start_file_job
|
||||||
|
|
||||||
|
actor = await _actor(request, admin=True)
|
||||||
|
filename = (file.filename or "document").replace("\\", "/").rsplit("/", 1)[-1][:240]
|
||||||
|
extension = Path(filename).suffix.lower()
|
||||||
|
if extension not in ALLOWED_EXTENSIONS:
|
||||||
|
await file.close()
|
||||||
|
raise HTTPException(status_code=415, detail="支持 Markdown、TXT、CSV、PDF、Word、Excel、PowerPoint 文件")
|
||||||
|
job = await _store(request).create_queued_import_job(
|
||||||
|
source_type="file", source_key=f"file-upload:{uuid4()}", source_name=filename,
|
||||||
|
trigger="local_file_upload", created_by=actor, base_id=assistant_base_id,
|
||||||
|
metadata={"type": "file", "filename": filename}, phase="uploading",
|
||||||
|
)
|
||||||
|
root = file_job_dir(job["id"])
|
||||||
|
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True)
|
||||||
|
path = root / f"original{extension}"
|
||||||
|
size, digest = 0, hashlib.sha256()
|
||||||
|
try:
|
||||||
|
with path.open("wb") as handle:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_FILE_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="单个文件最大 100 MB,请拆分后上传")
|
||||||
|
digest.update(chunk)
|
||||||
|
await asyncio.to_thread(handle.write, chunk)
|
||||||
|
if not size:
|
||||||
|
raise HTTPException(status_code=422, detail="上传文件为空")
|
||||||
|
await asyncio.to_thread(save_json, root / "manifest.json", {
|
||||||
|
"filename": filename, "stored_name": path.name, "digest": digest.hexdigest(), "actor": actor,
|
||||||
|
})
|
||||||
|
job = await _store(request).update_import_job(job["id"], phase="queued", counts={"uploaded_bytes": size})
|
||||||
|
start_file_job(request.app, job["id"])
|
||||||
|
return job
|
||||||
|
except Exception as exc:
|
||||||
|
await _store(request).update_import_job(job["id"], status="failed", phase="upload_failed", error=str(getattr(exc, "detail", exc)), completed=True)
|
||||||
|
await asyncio.to_thread(path.unlink, missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-jobs/{job_id}/retry-file", status_code=202)
|
||||||
|
async def retry_file_import(request: Request, job_id: str) -> dict[str, Any]:
|
||||||
|
from app.gateway.assistant_file_ingest import file_job_dir, retry_file_job
|
||||||
|
|
||||||
await _actor(request, admin=True)
|
await _actor(request, admin=True)
|
||||||
raise HTTPException(status_code=410, detail="助手知识库不支持导入任意本地文件,请从 WeKnora 普通知识库导出后导入")
|
store = _store(request)
|
||||||
|
job = await store.get_job(job_id)
|
||||||
|
if not job or job.get("trigger") != "local_file_upload":
|
||||||
|
raise HTTPException(status_code=404, detail="文件处理任务不存在")
|
||||||
|
if job["status"] == "completed":
|
||||||
|
raise HTTPException(status_code=409, detail="文件已处理完成")
|
||||||
|
if not (file_job_dir(job_id) / "manifest.json").exists():
|
||||||
|
raise HTTPException(status_code=409, detail="文件上传未完成,请重新上传")
|
||||||
|
if not await retry_file_job(request.app, job_id):
|
||||||
|
raise HTTPException(status_code=409, detail="文件仍在处理中")
|
||||||
|
return await store.get_job(job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/imports/weknora-package", status_code=202)
|
@router.post("/imports/weknora-package", status_code=202)
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Direct LLM chat — no lead agent, tools, skills, or memory.
|
||||||
|
|
||||||
|
Used by the frontend 「极速」mode: stream tokens from ``create_chat_model``
|
||||||
|
with thinking forced off so first-token latency matches a bare chat client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.config import get_app_config
|
||||||
|
from deerflow.models import create_chat_model
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/direct-chat", tags=["direct-chat"])
|
||||||
|
|
||||||
|
_HEARTBEAT_SECONDS = 15.0
|
||||||
|
_MAX_MESSAGES = 40
|
||||||
|
|
||||||
|
|
||||||
|
class DirectChatMessage(BaseModel):
|
||||||
|
role: Literal["user", "assistant", "system"]
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DirectChatRequest(BaseModel):
|
||||||
|
messages: list[DirectChatMessage] = Field(..., min_length=1)
|
||||||
|
model_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(chunk: Any) -> str:
|
||||||
|
content = getattr(chunk, "content", None)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts: list[str] = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif isinstance(block, dict) and isinstance(block.get("text"), str):
|
||||||
|
parts.append(block["text"])
|
||||||
|
return "".join(parts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_lc_messages(body: DirectChatRequest) -> list[BaseMessage]:
|
||||||
|
built: list[BaseMessage] = []
|
||||||
|
for item in body.messages[-_MAX_MESSAGES:]:
|
||||||
|
text = (item.content or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if item.role == "user":
|
||||||
|
built.append(HumanMessage(content=text))
|
||||||
|
elif item.role == "assistant":
|
||||||
|
built.append(AIMessage(content=text))
|
||||||
|
else:
|
||||||
|
built.append(SystemMessage(content=text))
|
||||||
|
if not any(isinstance(msg, HumanMessage) for msg in built):
|
||||||
|
raise HTTPException(status_code=400, detail="至少需要一条 user 消息")
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(event: str, data: Any) -> str:
|
||||||
|
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_model_name(name: str | None) -> str:
|
||||||
|
config = get_app_config()
|
||||||
|
if not name:
|
||||||
|
if not config.models:
|
||||||
|
raise HTTPException(status_code=400, detail="未配置可用模型")
|
||||||
|
return config.models[0].name
|
||||||
|
if config.get_model_config(name) is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"模型不存在: {name}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
async def _direct_chat_event_stream(body: DirectChatRequest, model_name: str) -> AsyncIterator[str]:
|
||||||
|
lc_messages = _build_lc_messages(body)
|
||||||
|
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
|
||||||
|
pieces: list[str] = []
|
||||||
|
|
||||||
|
async def produce() -> None:
|
||||||
|
try:
|
||||||
|
model = create_chat_model(
|
||||||
|
name=model_name,
|
||||||
|
thinking_enabled=False,
|
||||||
|
force_disable_thinking=True,
|
||||||
|
)
|
||||||
|
async for chunk in model.astream(lc_messages):
|
||||||
|
text = _chunk_text(chunk)
|
||||||
|
if text:
|
||||||
|
pieces.append(text)
|
||||||
|
await queue.put(("delta", {"text": text}))
|
||||||
|
except Exception as exc: # noqa: BLE001 — stream errors become SSE error events
|
||||||
|
logger.exception("direct_chat 调用模型失败 (model=%s)", model_name)
|
||||||
|
await queue.put(("error", {"detail": f"模型调用失败:{exc}"}))
|
||||||
|
finally:
|
||||||
|
await queue.put(("__end__", None))
|
||||||
|
|
||||||
|
task = asyncio.create_task(produce())
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event, data = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
continue
|
||||||
|
if event == "__end__":
|
||||||
|
break
|
||||||
|
if event == "error":
|
||||||
|
yield _sse("error", data)
|
||||||
|
return
|
||||||
|
yield _sse(event, data)
|
||||||
|
|
||||||
|
answer = "".join(pieces)
|
||||||
|
if not answer:
|
||||||
|
yield _sse("error", {"detail": "模型未返回任何文本。"})
|
||||||
|
return
|
||||||
|
yield _sse("result", {"answer": answer, "model_name": model_name})
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stream")
|
||||||
|
async def direct_chat_stream(body: DirectChatRequest) -> StreamingResponse:
|
||||||
|
"""Stream a plain chat completion. No agent graph, tools, or skills."""
|
||||||
|
model_name = _resolve_model_name(body.model_name)
|
||||||
|
_build_lc_messages(body)
|
||||||
|
return StreamingResponse(
|
||||||
|
_direct_chat_event_stream(body, model_name),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -56,6 +56,22 @@ class KnowledgeBaseProductVersionUpdateRequest(BaseModel):
|
|||||||
version: Literal["v1", "v2"]
|
version: Literal["v1", "v2"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrdinaryKnowledgeLanding(BaseModel):
|
||||||
|
mode: Literal["wiki", "weknora"] = "wiki"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||||
|
async def get_ordinary_knowledge_landing() -> OrdinaryKnowledgeLanding:
|
||||||
|
return OrdinaryKnowledgeLanding(mode=load_system_settings().ordinary_knowledge_landing)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/ordinary-knowledge-landing", response_model=OrdinaryKnowledgeLanding)
|
||||||
|
async def update_ordinary_knowledge_landing(body: OrdinaryKnowledgeLanding, request: Request) -> OrdinaryKnowledgeLanding:
|
||||||
|
await _require_admin(request)
|
||||||
|
saved = save_system_settings(load_system_settings().model_copy(update={"ordinary_knowledge_landing": body.mode}))
|
||||||
|
return OrdinaryKnowledgeLanding(mode=saved.ordinary_knowledge_landing)
|
||||||
|
|
||||||
|
|
||||||
class PromptPrefixUpdateRequest(BaseModel):
|
class PromptPrefixUpdateRequest(BaseModel):
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
switch_label: str | None = None
|
switch_label: str | None = None
|
||||||
|
|||||||
@@ -33,6 +33,26 @@ concurrency:
|
|||||||
fail_open: true
|
fail_open: true
|
||||||
|
|
||||||
models:
|
models:
|
||||||
|
- name: qwen3.6
|
||||||
|
display_name: qwen3.6
|
||||||
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
|
model: qwen3.6
|
||||||
|
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||||
|
base_url: http://119.254.86.251:8001/v1
|
||||||
|
request_timeout: 30.0
|
||||||
|
max_retries: 2
|
||||||
|
stream_usage: true
|
||||||
|
supports_thinking: true
|
||||||
|
supports_reasoning_effort: false
|
||||||
|
supports_vision: false
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
when_thinking_disabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: false
|
||||||
- name: MiniMax-M2.7-fash
|
- name: MiniMax-M2.7-fash
|
||||||
display_name: MiniMax-M2.7-fash
|
display_name: MiniMax-M2.7-fash
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
@@ -59,16 +79,24 @@ models:
|
|||||||
supports_vision: false
|
supports_vision: false
|
||||||
- name: qwen3.6
|
- name: qwen3.6
|
||||||
display_name: qwen3.6
|
display_name: qwen3.6
|
||||||
use: langchain_openai:ChatOpenAI
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
model: qwen3.6
|
model: qwen3.6
|
||||||
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
|
||||||
base_url: http://119.254.86.251:8001/v1
|
base_url: http://119.254.86.251:8001/v1
|
||||||
request_timeout: 30.0
|
request_timeout: 30.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
stream_usage: true
|
stream_usage: true
|
||||||
supports_thinking: false
|
supports_thinking: true
|
||||||
supports_reasoning_effort: false
|
supports_reasoning_effort: false
|
||||||
supports_vision: false
|
supports_vision: false
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
when_thinking_disabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: false
|
||||||
- name: zai-org/GLM-5-FP8
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: zai-org/GLM-5-FP8
|
display_name: zai-org/GLM-5-FP8
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
@@ -466,7 +494,7 @@ checkpointer:
|
|||||||
# 三层覆盖优先级(高->低):
|
# 三层覆盖优先级(高->低):
|
||||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||||
memory:
|
memory:
|
||||||
enabled: true
|
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
|
||||||
version:
|
version:
|
||||||
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
|
||||||
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
|
||||||
|
|||||||
@@ -123,6 +123,27 @@ models:
|
|||||||
|
|
||||||
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
|
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
|
||||||
|
|
||||||
|
**vLLM (per-model, do not change other entries)**:
|
||||||
|
|
||||||
|
`use` is per model. SiliconFlow / DeepSeek entries should stay on `langchain_openai:ChatOpenAI`. For a self-hosted vLLM reasoning model (thinking may arrive as `reasoning` on newer vLLM, or `reasoning_content` on older builds), set only that one entry to `VllmChatModel`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
models:
|
||||||
|
- name: qwen3.6
|
||||||
|
display_name: qwen3.6
|
||||||
|
use: deerflow.models.vllm_provider:VllmChatModel
|
||||||
|
model: qwen3.6
|
||||||
|
api_key: $VLLM_API_KEY
|
||||||
|
base_url: http://127.0.0.1:8001/v1
|
||||||
|
supports_thinking: true
|
||||||
|
when_thinking_enabled:
|
||||||
|
extra_body:
|
||||||
|
chat_template_kwargs:
|
||||||
|
enable_thinking: true
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `langchain_openai:ChatOpenAI` path also maps both thinking field names onto the UI's `reasoning_content`. Use `VllmChatModel` when follow-up turns must echo vLLM's original `reasoning` field.
|
||||||
|
|
||||||
**Thinking Models**:
|
**Thinking Models**:
|
||||||
Some models support "thinking" mode for complex reasoning:
|
Some models support "thinking" mode for complex reasoning:
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -433,7 +433,9 @@ def _build_middlewares(
|
|||||||
|
|
||||||
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
|
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
|
||||||
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
|
||||||
if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
|
memory_cfg = getattr(resolved_app_config, "memory", None)
|
||||||
|
memory_globally_enabled = memory_cfg is not None and getattr(memory_cfg, "enabled", False)
|
||||||
|
if memory_globally_enabled and not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
|
||||||
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
|
||||||
|
|
||||||
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
|
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
|
||||||
|
|||||||
+6
-1
@@ -883,11 +883,16 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
from deerflow.agents.memory.manager import build_memory_manager
|
from deerflow.agents.memory.manager import build_memory_manager
|
||||||
from deerflow.config.memory_config import get_effective_memory_config
|
from deerflow.config.memory_config import get_effective_memory_config, get_memory_config
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
if not get_memory_config().enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
user_id = get_effective_user_id()
|
user_id = get_effective_user_id()
|
||||||
config = get_effective_memory_config(user_id)
|
config = get_effective_memory_config(user_id)
|
||||||
|
if not config.enabled or not config.injection.enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
manager = build_memory_manager(
|
manager = build_memory_manager(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
============
|
============
|
||||||
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
|
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
|
||||||
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
|
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
|
||||||
(有 per-user 记录时自动强制 enabled=True)
|
(仅白名单字段,不能覆盖 ``enabled``)
|
||||||
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
|
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
|
||||||
3. Pydantic 默认值
|
3. Pydantic 默认值
|
||||||
|
|
||||||
@@ -254,13 +254,12 @@ def get_effective_memory_config(user_id: str) -> MemoryConfig:
|
|||||||
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
|
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
|
||||||
|
|
||||||
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
|
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
|
||||||
有 per-user 记录时自动强制 enabled=True。
|
``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。
|
||||||
"""
|
"""
|
||||||
base = _memory_config.model_dump()
|
base = _memory_config.model_dump()
|
||||||
recall_budget = _recall_budget_cache.get(user_id)
|
recall_budget = _recall_budget_cache.get(user_id)
|
||||||
if recall_budget is not None:
|
if recall_budget is not None:
|
||||||
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
|
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
|
||||||
base["enabled"] = True
|
|
||||||
try:
|
try:
|
||||||
return MemoryConfig(**base)
|
return MemoryConfig(**base)
|
||||||
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
|
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ class SystemSettings(BaseModel):
|
|||||||
default="v1",
|
default="v1",
|
||||||
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
|
description="v1 opens the ordinary WeKnora experience; v2 opens the DeerFlow assistant knowledge base.",
|
||||||
)
|
)
|
||||||
|
ordinary_knowledge_landing: Literal["wiki", "weknora"] = "wiki"
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
|
|||||||
return disable_kwargs
|
return disable_kwargs
|
||||||
|
|
||||||
|
|
||||||
|
# Qwen3.8 chat templates only accept these three effort names.
|
||||||
|
_QWEN_REASONING_EFFORT = {
|
||||||
|
"minimal": "low",
|
||||||
|
"low": "low",
|
||||||
|
"medium": "medium",
|
||||||
|
"high": "xhigh",
|
||||||
|
"xhigh": "xhigh",
|
||||||
|
}
|
||||||
|
|
||||||
|
_OPENAI_THINKING_OFF_EXTRA_BODY = {
|
||||||
|
"enable_thinking": False,
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
|
"thinking": {"type": "disabled"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_vllm_provider(model_use: str) -> bool:
|
||||||
|
lowered = (model_use or "").lower()
|
||||||
|
return "vllm_provider" in lowered or lowered.endswith(":vllmchatmodel")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_qwen38_model(model_config) -> bool:
|
||||||
|
blob = f"{getattr(model_config, 'name', '')} {getattr(model_config, 'model', '')}".lower()
|
||||||
|
return "qwen3.8" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def _should_control_openai_thinking(model_config) -> bool:
|
||||||
|
"""Whether this OpenAI-compatible model needs explicit thinking on/off flags.
|
||||||
|
|
||||||
|
Qwen3.8 (and current vLLM reasoning parsers) default to thinking at ``xhigh``.
|
||||||
|
If DeerFlow does not send ``enable_thinking: false``, the first visible answer
|
||||||
|
token waits until that hidden reasoning finishes — third-party chat UIs that
|
||||||
|
disable thinking or stream the reasoning field therefore feel much faster.
|
||||||
|
"""
|
||||||
|
if getattr(model_config, "supports_thinking", False):
|
||||||
|
return True
|
||||||
|
if _is_vllm_provider(getattr(model_config, "use", "")):
|
||||||
|
return True
|
||||||
|
return _is_qwen38_model(model_config)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_qwen_reasoning_effort(effort: object) -> str | None:
|
||||||
|
if effort is None:
|
||||||
|
return None
|
||||||
|
return _QWEN_REASONING_EFFORT.get(str(effort).strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_openai_thinking_control(
|
||||||
|
model_config,
|
||||||
|
model_class,
|
||||||
|
model_settings_from_config: dict,
|
||||||
|
*,
|
||||||
|
thinking_enabled: bool,
|
||||||
|
reasoning_effort: object,
|
||||||
|
) -> None:
|
||||||
|
"""Inject vLLM/Qwen thinking flags when the UI toggle is on or off."""
|
||||||
|
if "extra_body" not in getattr(model_class, "model_fields", {}):
|
||||||
|
return
|
||||||
|
qwen_vllm = _is_vllm_provider(getattr(model_config, "use", "")) or _is_qwen38_model(model_config)
|
||||||
|
if thinking_enabled:
|
||||||
|
if not qwen_vllm:
|
||||||
|
return
|
||||||
|
enable_body: dict = {
|
||||||
|
"enable_thinking": True,
|
||||||
|
"chat_template_kwargs": {"enable_thinking": True},
|
||||||
|
}
|
||||||
|
mapped_effort = _map_qwen_reasoning_effort(reasoning_effort)
|
||||||
|
if mapped_effort:
|
||||||
|
enable_body["chat_template_kwargs"]["reasoning_effort"] = mapped_effort
|
||||||
|
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
||||||
|
model_settings_from_config.get("extra_body"),
|
||||||
|
enable_body,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not _should_control_openai_thinking(model_config):
|
||||||
|
return
|
||||||
|
disable_body = (
|
||||||
|
{
|
||||||
|
"enable_thinking": False,
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
|
}
|
||||||
|
if qwen_vllm
|
||||||
|
else _OPENAI_THINKING_OFF_EXTRA_BODY
|
||||||
|
)
|
||||||
|
model_settings_from_config["extra_body"] = _deep_merge_dicts(
|
||||||
|
model_settings_from_config.get("extra_body"),
|
||||||
|
disable_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
|
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
|
||||||
"""Enable stream usage for OpenAI-compatible models unless explicitly configured.
|
"""Enable stream usage for OpenAI-compatible models unless explicitly configured.
|
||||||
|
|
||||||
@@ -127,10 +217,19 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
|||||||
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
|
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
|
||||||
# Native langchain_anthropic: thinking is a direct constructor parameter
|
# Native langchain_anthropic: thinking is a direct constructor parameter
|
||||||
model_settings_from_config["thinking"] = {"type": "disabled"}
|
model_settings_from_config["thinking"] = {"type": "disabled"}
|
||||||
|
requested_reasoning_effort = kwargs.get("reasoning_effort")
|
||||||
if not model_config.supports_reasoning_effort:
|
if not model_config.supports_reasoning_effort:
|
||||||
kwargs.pop("reasoning_effort", None)
|
kwargs.pop("reasoning_effort", None)
|
||||||
model_settings_from_config.pop("reasoning_effort", None)
|
model_settings_from_config.pop("reasoning_effort", None)
|
||||||
|
|
||||||
|
_apply_openai_thinking_control(
|
||||||
|
model_config,
|
||||||
|
model_class,
|
||||||
|
model_settings_from_config,
|
||||||
|
thinking_enabled=thinking_enabled,
|
||||||
|
reasoning_effort=requested_reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
# Hard override: force-disable thinking regardless of the per-model config shape.
|
# Hard override: force-disable thinking regardless of the per-model config shape.
|
||||||
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
|
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
|
||||||
# other providers would reject the kwarg, so they are skipped (they ignore it).
|
# other providers would reject the kwarg, so they are skipped (they ignore it).
|
||||||
|
|||||||
+25
-29
@@ -33,6 +33,7 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResu
|
|||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||||
|
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, extract_reasoning_text, fill_ui_reasoning_content, restore_reasoning_fields
|
||||||
|
|
||||||
|
|
||||||
class PatchedChatOpenAI(ChatOpenAI):
|
class PatchedChatOpenAI(ChatOpenAI):
|
||||||
@@ -118,12 +119,12 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
|
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
|
||||||
choice = choices[0] if choices else {}
|
choice = choices[0] if choices else {}
|
||||||
delta = choice.get("delta") if isinstance(choice, dict) else None
|
delta = choice.get("delta") if isinstance(choice, dict) else None
|
||||||
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
|
if isinstance(generation_chunk.message, AIMessageChunk):
|
||||||
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
|
generation_chunk.message = apply_reasoning_from_payload(
|
||||||
generation_chunk.message = _append_reasoning_content(
|
|
||||||
generation_chunk.message,
|
generation_chunk.message,
|
||||||
reasoning_content,
|
delta if isinstance(delta, dict) else None,
|
||||||
)
|
)
|
||||||
|
generation_chunk.message = fill_ui_reasoning_content(generation_chunk.message)
|
||||||
return generation_chunk
|
return generation_chunk
|
||||||
|
|
||||||
def _create_chat_result(
|
def _create_chat_result(
|
||||||
@@ -141,13 +142,19 @@ class PatchedChatOpenAI(ChatOpenAI):
|
|||||||
message = generation.message
|
message = generation.message
|
||||||
choice = choices[index] if index < len(choices) else {}
|
choice = choices[index] if index < len(choices) else {}
|
||||||
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
||||||
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None)
|
if isinstance(message, AIMessage):
|
||||||
if reasoning_content and isinstance(message, AIMessage):
|
updated = apply_reasoning_from_payload(
|
||||||
message = _append_reasoning_content(message, reasoning_content)
|
message,
|
||||||
generation = ChatGeneration(
|
choice_message if isinstance(choice_message, dict) else None,
|
||||||
message=message,
|
append=False,
|
||||||
generation_info=generation.generation_info,
|
|
||||||
)
|
)
|
||||||
|
updated = fill_ui_reasoning_content(updated)
|
||||||
|
if updated is not message:
|
||||||
|
message = updated
|
||||||
|
generation = ChatGeneration(
|
||||||
|
message=message,
|
||||||
|
generation_info=generation.generation_info,
|
||||||
|
)
|
||||||
generations.append(generation)
|
generations.append(generation)
|
||||||
|
|
||||||
return ChatResult(generations=generations, llm_output=result.llm_output)
|
return ChatResult(generations=generations, llm_output=result.llm_output)
|
||||||
@@ -195,33 +202,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
|
|||||||
|
|
||||||
|
|
||||||
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
|
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
|
||||||
"""Re-inject ``reasoning_content`` onto assistant payload message.
|
"""Re-inject thinking fields onto assistant payload message.
|
||||||
|
|
||||||
Some thinking-enabled OpenAI-compatible providers require each historical
|
Some thinking-enabled OpenAI-compatible providers require each historical
|
||||||
assistant turn to include the original ``reasoning_content`` in subsequent
|
assistant turn to include the original thinking field in subsequent
|
||||||
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does
|
requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
|
||||||
not always serialize it back into ``messages``.
|
vLLM use ``reasoning_content``. Echo only the fields this turn originally
|
||||||
|
carried so other models are not given an extra field.
|
||||||
"""
|
"""
|
||||||
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
|
restore_reasoning_fields(payload_msg, orig_msg)
|
||||||
if reasoning_content is not None:
|
|
||||||
payload_msg["reasoning_content"] = reasoning_content
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_reasoning_content(payload: dict | None) -> str | None:
|
def _extract_reasoning_content(payload: dict | None) -> str | None:
|
||||||
"""Extract reasoning text from provider payload delta/message objects."""
|
"""Extract reasoning text from provider payload delta/message objects."""
|
||||||
if not isinstance(payload, dict):
|
return extract_reasoning_text(payload)
|
||||||
return None
|
|
||||||
reasoning_content = payload.get("reasoning_content")
|
|
||||||
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None
|
|
||||||
|
|
||||||
|
|
||||||
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
|
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
|
||||||
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
|
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
|
||||||
additional_kwargs = dict(message.additional_kwargs)
|
return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
|
||||||
existing = additional_kwargs.get("reasoning_content")
|
|
||||||
if isinstance(existing, str) and existing:
|
|
||||||
if reasoning_content not in existing:
|
|
||||||
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
|
|
||||||
else:
|
|
||||||
additional_kwargs["reasoning_content"] = reasoning_content
|
|
||||||
return message.model_copy(update={"additional_kwargs": additional_kwargs})
|
|
||||||
+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
|
||||||
+18
-47
@@ -6,15 +6,16 @@ from assistant messages and streaming deltas. That breaks interleaved
|
|||||||
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
|
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
|
||||||
be echoed back on subsequent turns.
|
be echoed back on subsequent turns.
|
||||||
|
|
||||||
This provider preserves ``reasoning`` on:
|
This provider preserves ``reasoning`` (newer vLLM) and
|
||||||
- non-streaming responses
|
``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
|
||||||
- streaming deltas
|
- non-streaming responses
|
||||||
- multi-turn request payloads
|
- streaming deltas
|
||||||
|
- multi-turn request payloads
|
||||||
|
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ from langchain_openai import ChatOpenAI
|
|||||||
from langchain_openai.chat_models.base import _create_usage_metadata
|
from langchain_openai.chat_models.base import _create_usage_metadata
|
||||||
|
|
||||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||||
|
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
|
||||||
|
|
||||||
|
|
||||||
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
||||||
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
def _reasoning_to_text(reasoning: Any) -> str:
|
def _reasoning_to_text(reasoning: Any) -> str:
|
||||||
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
|
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
|
||||||
if isinstance(reasoning, str):
|
from deerflow.models.reasoning_fields import reasoning_to_text
|
||||||
return reasoning
|
|
||||||
|
|
||||||
if isinstance(reasoning, list):
|
return reasoning_to_text(reasoning)
|
||||||
parts = [_reasoning_to_text(item) for item in reasoning]
|
|
||||||
return "".join(part for part in parts if part)
|
|
||||||
|
|
||||||
if isinstance(reasoning, dict):
|
|
||||||
for key in ("text", "content", "reasoning"):
|
|
||||||
value = reasoning.get(key)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
if value is not None:
|
|
||||||
text = _reasoning_to_text(value)
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
try:
|
|
||||||
return json.dumps(reasoning, ensure_ascii=False)
|
|
||||||
except TypeError:
|
|
||||||
return str(reasoning)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.dumps(reasoning, ensure_ascii=False)
|
|
||||||
except TypeError:
|
|
||||||
return str(reasoning)
|
|
||||||
|
|
||||||
|
|
||||||
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
|
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
|
||||||
@@ -106,12 +86,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
|
|||||||
function_call["name"] = ""
|
function_call["name"] = ""
|
||||||
additional_kwargs["function_call"] = function_call
|
additional_kwargs["function_call"] = function_call
|
||||||
|
|
||||||
reasoning = _dict.get("reasoning")
|
seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
|
||||||
if reasoning is not None:
|
additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
|
||||||
additional_kwargs["reasoning"] = reasoning
|
|
||||||
reasoning_text = _reasoning_to_text(reasoning)
|
|
||||||
if reasoning_text:
|
|
||||||
additional_kwargs["reasoning_content"] = reasoning_text
|
|
||||||
|
|
||||||
tool_call_chunks = []
|
tool_call_chunks = []
|
||||||
if raw_tool_calls := _dict.get("tool_calls"):
|
if raw_tool_calls := _dict.get("tool_calls"):
|
||||||
@@ -151,11 +127,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
|
|||||||
|
|
||||||
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
|
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
|
||||||
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
|
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
|
||||||
reasoning = orig_msg.additional_kwargs.get("reasoning")
|
restore_reasoning_fields(payload_msg, orig_msg)
|
||||||
if reasoning is None:
|
|
||||||
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
|
|
||||||
if reasoning is not None:
|
|
||||||
payload_msg["reasoning"] = reasoning
|
|
||||||
|
|
||||||
|
|
||||||
class VllmChatModel(ChatOpenAI):
|
class VllmChatModel(ChatOpenAI):
|
||||||
@@ -204,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
|
|||||||
message = generation.message
|
message = generation.message
|
||||||
if not isinstance(message, AIMessage):
|
if not isinstance(message, AIMessage):
|
||||||
continue
|
continue
|
||||||
reasoning = choice.get("message", {}).get("reasoning")
|
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
|
||||||
if reasoning is None:
|
generation.message = apply_reasoning_from_payload(
|
||||||
continue
|
message,
|
||||||
message.additional_kwargs["reasoning"] = reasoning
|
choice_message if isinstance(choice_message, dict) else None,
|
||||||
reasoning_text = _reasoning_to_text(reasoning)
|
append=False,
|
||||||
if reasoning_text:
|
)
|
||||||
message.additional_kwargs["reasoning_content"] = reasoning_text
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
+2
@@ -241,6 +241,8 @@ class AssistantKnowledgeRepository:
|
|||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
metadata = package.get("source") or {}
|
metadata = package.get("source") or {}
|
||||||
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
|
provider = str(metadata.get("provider") or metadata.get("type") or "").strip().casefold()
|
||||||
|
if provider == "file" and metadata.get("digest"):
|
||||||
|
return f"file:{metadata['digest']}", str(metadata.get("filename") or fallback_name)
|
||||||
if provider == "weknora":
|
if provider == "weknora":
|
||||||
source_key = str(
|
source_key = str(
|
||||||
metadata.get("knowledge_base_id")
|
metadata.get("knowledge_base_id")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.gateway import assistant_file_ingest as ingest
|
||||||
|
from app.gateway.routers import assistant_knowledge as router
|
||||||
|
from deerflow.knowledge.schemas import NoteDraft
|
||||||
|
from deerflow.persistence.assistant_knowledge import AssistantKnowledgeRepository
|
||||||
|
from deerflow.persistence.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_topic_across_document_parts_preserves_all_knowledge():
|
||||||
|
package = ingest.notes_package([
|
||||||
|
{"title": "设备维护", "content_md": "每周清理过滤网", "tags": ["运维"]},
|
||||||
|
{"title": "设备 维护", "content_md": "每月检查电机温度", "tags": ["设备"]},
|
||||||
|
], {"digest": "document", "filename": "维护指南.md"})
|
||||||
|
assert len(package["wiki_pages"]) == 1
|
||||||
|
assert "每周清理过滤网" in package["wiki_pages"][0]["content"]
|
||||||
|
assert "每月检查电机温度" in package["wiki_pages"][0]["content"]
|
||||||
|
assert package["wiki_pages"][0]["tags"] == ["运维", "设备"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_standalone_upload_vectors_retry_and_dedup(tmp_path, monkeypatch):
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
store = AssistantKnowledgeRepository(async_sessionmaker(engine, expire_on_commit=False))
|
||||||
|
base = await store.create_base(name="独立文档库", created_by="admin")
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router.router)
|
||||||
|
app.state.assistant_knowledge_store = store
|
||||||
|
app.state.llmwiki_embedding = None
|
||||||
|
monkeypatch.setattr(ingest, "runtime_home", lambda: tmp_path)
|
||||||
|
extraction_calls = []
|
||||||
|
|
||||||
|
async def actor(*args, **kwargs):
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
async def extract(text, **kwargs):
|
||||||
|
extraction_calls.append(text)
|
||||||
|
return [NoteDraft(title="冷却系统", summary="摘要占位", content_md="# 冷却系统\n风扇的额定转速为每分钟 1200 转。", tags=["设备"], entities=[{"name": "风扇", "type": "product"}])]
|
||||||
|
|
||||||
|
class Encoder:
|
||||||
|
fingerprint = "local-test-3"
|
||||||
|
dimensions = 3
|
||||||
|
config = SimpleNamespace(model="offline-test")
|
||||||
|
|
||||||
|
async def embed_texts(self, texts):
|
||||||
|
return [[1.0, 0.0, 0.0] for _ in texts]
|
||||||
|
|
||||||
|
async def embed_query(self, text):
|
||||||
|
return [1.0, 0.0, 0.0]
|
||||||
|
|
||||||
|
monkeypatch.setattr(router, "_actor", actor)
|
||||||
|
monkeypatch.setattr(ingest, "extract_document_llm_multi", extract)
|
||||||
|
|
||||||
|
async def finish():
|
||||||
|
if ingest._tasks:
|
||||||
|
await asyncio.gather(*list(ingest._tasks.values()))
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {"assistant_base_id": base["id"]}
|
||||||
|
response = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("../../设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||||
|
assert response.status_code == 202, response.text
|
||||||
|
job_id = response.json()["id"]
|
||||||
|
await finish()
|
||||||
|
assert (await store.get_job(job_id))["status"] == "failed"
|
||||||
|
assert (ingest.file_job_dir(job_id) / "wiki.json").exists()
|
||||||
|
app.state.llmwiki_embedding = Encoder()
|
||||||
|
retry = await client.post(f"/api/assistant-knowledge/import-jobs/{job_id}/retry-file")
|
||||||
|
assert retry.status_code == 202
|
||||||
|
await finish()
|
||||||
|
job = await store.get_job(job_id)
|
||||||
|
assert job["status"] == "completed", job
|
||||||
|
assert job["counts"]["vectors"] == 1
|
||||||
|
assert len(extraction_calls) == 1
|
||||||
|
results = await store.search_pages(base_ids=[base["id"]], query="额定转速", embedding_client=app.state.llmwiki_embedding)
|
||||||
|
assert results and results[0]["retrieval_mode"] == "vector"
|
||||||
|
assert "1200" in results[0]["matched_section"]["content"]
|
||||||
|
assert "摘要占位" not in results[0]["matched_section"]["content"]
|
||||||
|
global_base = await store.get_base()
|
||||||
|
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||||
|
again = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("设备.md", "风扇额定转速为 1200 转。".encode())})
|
||||||
|
assert again.status_code == 202
|
||||||
|
await finish()
|
||||||
|
assert len(await store.list_sources(base_id=base["id"])) == 1
|
||||||
|
assert len(await store.list_pages(base_id=global_base["id"])) == 1
|
||||||
|
bad = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("script.py", b"print('no')")})
|
||||||
|
assert bad.status_code == 415
|
||||||
|
empty = await client.post("/api/assistant-knowledge/imports/files", data=payload, files={"file": ("empty.txt", b"")})
|
||||||
|
assert empty.status_code == 422
|
||||||
|
finally:
|
||||||
|
await ingest.stop_file_jobs()
|
||||||
|
await engine.dispose()
|
||||||
@@ -239,3 +239,67 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert chat_model.extra_body is None
|
assert chat_model.extra_body is None
|
||||||
|
|
||||||
|
|
||||||
|
def _extra_body(chat_model) -> dict:
|
||||||
|
body = chat_model.extra_body or {}
|
||||||
|
return body if isinstance(body, dict) else dict(body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen38_flash_mode_disables_default_xhigh_thinking() -> None:
|
||||||
|
"""Qwen3.8 defaults to thinking=xhigh; flash must send enable_thinking=false."""
|
||||||
|
config = AppConfig.model_construct(
|
||||||
|
models=[
|
||||||
|
ModelConfig(
|
||||||
|
name="qwen3.8-27b",
|
||||||
|
use="langchain_openai:ChatOpenAI",
|
||||||
|
model="Qwen/Qwen3.8-27B",
|
||||||
|
api_key="test-key",
|
||||||
|
base_url="http://vllm.example/v1",
|
||||||
|
supports_thinking=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_model = create_chat_model("qwen3.8-27b", thinking_enabled=False, app_config=config)
|
||||||
|
extra_body = _extra_body(chat_model)
|
||||||
|
|
||||||
|
assert extra_body.get("enable_thinking") is False
|
||||||
|
assert extra_body.get("chat_template_kwargs", {}).get("enable_thinking") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_qwen38_thinking_mode_sends_medium_reasoning_effort() -> None:
|
||||||
|
config = AppConfig.model_construct(
|
||||||
|
models=[
|
||||||
|
ModelConfig(
|
||||||
|
name="qwen3.8-27b",
|
||||||
|
use="langchain_openai:ChatOpenAI",
|
||||||
|
model="qwen3.8-27b",
|
||||||
|
api_key="test-key",
|
||||||
|
base_url="http://vllm.example/v1",
|
||||||
|
supports_thinking=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
chat_model = create_chat_model(
|
||||||
|
"qwen3.8-27b",
|
||||||
|
thinking_enabled=True,
|
||||||
|
reasoning_effort="medium",
|
||||||
|
app_config=config,
|
||||||
|
)
|
||||||
|
extra_body = _extra_body(chat_model)
|
||||||
|
kwargs = extra_body.get("chat_template_kwargs") or {}
|
||||||
|
|
||||||
|
assert extra_body.get("enable_thinking") is True
|
||||||
|
assert kwargs.get("enable_thinking") is True
|
||||||
|
assert kwargs.get("reasoning_effort") == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
|
||||||
|
from deerflow.models.patched_openai import _extract_reasoning_content
|
||||||
|
|
||||||
|
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
|
||||||
|
assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理"
|
||||||
|
assert _extract_reasoning_content({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
|
||||||
|
assert _extract_reasoning_content({"content": "答案"}) is None
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Reasoning field compatibility: vLLM `reasoning` vs older `reasoning_content`."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||||
|
|
||||||
|
from deerflow.models.patched_openai import _extract_reasoning_content, _restore_reasoning_content
|
||||||
|
from deerflow.models.reasoning_fields import (
|
||||||
|
apply_reasoning_from_payload,
|
||||||
|
extract_reasoning_text,
|
||||||
|
fill_ui_reasoning_content,
|
||||||
|
restore_reasoning_fields,
|
||||||
|
)
|
||||||
|
from deerflow.models.vllm_provider import (
|
||||||
|
_convert_delta_to_message_chunk_with_reasoning,
|
||||||
|
_restore_reasoning_field,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
|
||||||
|
assert extract_reasoning_text({"reasoning": "先分析问题"}) == "先分析问题"
|
||||||
|
assert extract_reasoning_text({"reasoning_content": "逐步推理"}) == "逐步推理"
|
||||||
|
assert extract_reasoning_text({"content": "答案"}) is None
|
||||||
|
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_reasoning_reads_structured_reasoning_object() -> None:
|
||||||
|
assert extract_reasoning_text({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
|
||||||
|
assert extract_reasoning_text({"reasoning": {"text": "逐步分析"}}) == "逐步分析"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_prefers_reasoning_content_when_both_present() -> None:
|
||||||
|
assert (
|
||||||
|
extract_reasoning_text({"reasoning_content": "旧字段", "reasoning": "新字段"})
|
||||||
|
== "旧字段"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_new_vllm_reasoning_keeps_ui_field_and_wire_field() -> None:
|
||||||
|
message = apply_reasoning_from_payload(AIMessage(content="答案"), {"reasoning": "新版思考"})
|
||||||
|
assert message.additional_kwargs["reasoning"] == "新版思考"
|
||||||
|
assert message.additional_kwargs["reasoning_content"] == "新版思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_old_reasoning_content_does_not_invent_reasoning() -> None:
|
||||||
|
message = apply_reasoning_from_payload(
|
||||||
|
AIMessage(content="答案"),
|
||||||
|
{"reasoning_content": "旧版思考"},
|
||||||
|
)
|
||||||
|
assert "reasoning" not in message.additional_kwargs
|
||||||
|
assert message.additional_kwargs["reasoning_content"] == "旧版思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_new_vllm_echoes_reasoning_only() -> None:
|
||||||
|
orig = AIMessage(
|
||||||
|
content="答案",
|
||||||
|
additional_kwargs={"reasoning": "新版思考", "reasoning_content": "新版思考"},
|
||||||
|
)
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
restore_reasoning_fields(payload, orig)
|
||||||
|
assert payload["reasoning"] == "新版思考"
|
||||||
|
assert "reasoning_content" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_old_vllm_echoes_reasoning_content_only() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
restore_reasoning_fields(payload, orig)
|
||||||
|
assert payload["reasoning_content"] == "旧版思考"
|
||||||
|
assert "reasoning" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_patched_openai_restore_does_not_add_reasoning_for_deepseek_turns() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "DeepSeek思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
_restore_reasoning_content(payload, orig)
|
||||||
|
assert payload == {"role": "assistant", "content": "答案", "reasoning_content": "DeepSeek思考"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_stream_delta_reasoning_maps_to_ui_field() -> None:
|
||||||
|
chunk = _convert_delta_to_message_chunk_with_reasoning(
|
||||||
|
{"role": "assistant", "content": "", "reasoning": "流式思考"},
|
||||||
|
AIMessageChunk,
|
||||||
|
)
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
assert chunk.additional_kwargs["reasoning"] == "流式思考"
|
||||||
|
assert chunk.additional_kwargs["reasoning_content"] == "流式思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_stream_delta_reasoning_content_does_not_copy_into_reasoning() -> None:
|
||||||
|
chunk = _convert_delta_to_message_chunk_with_reasoning(
|
||||||
|
{"role": "assistant", "content": "", "reasoning_content": "旧流式思考"},
|
||||||
|
AIMessageChunk,
|
||||||
|
)
|
||||||
|
assert isinstance(chunk, AIMessageChunk)
|
||||||
|
assert "reasoning" not in chunk.additional_kwargs
|
||||||
|
assert chunk.additional_kwargs["reasoning_content"] == "旧流式思考"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vllm_restore_old_field_stays_on_reasoning_content() -> None:
|
||||||
|
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
|
||||||
|
payload: dict = {"role": "assistant", "content": "答案"}
|
||||||
|
_restore_reasoning_field(payload, orig)
|
||||||
|
assert payload["reasoning_content"] == "旧版思考"
|
||||||
|
assert "reasoning" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_fill_ui_reasoning_content_from_langchain_kwargs() -> None:
|
||||||
|
message = AIMessage(content="答案", additional_kwargs={"reasoning": "网关已放入reasoning"})
|
||||||
|
filled = fill_ui_reasoning_content(message)
|
||||||
|
assert filled.additional_kwargs["reasoning_content"] == "网关已放入reasoning"
|
||||||
@@ -264,7 +264,7 @@ ai_writing:
|
|||||||
# 三层覆盖优先级(高->低):
|
# 三层覆盖优先级(高->低):
|
||||||
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
|
||||||
memory:
|
memory:
|
||||||
enabled: true
|
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
|
||||||
version: v1
|
version: v1
|
||||||
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
|
||||||
# builtin 始终启用,这里只决定 external 选哪个
|
# builtin 始终启用,这里只决定 external 选哪个
|
||||||
|
|||||||
Reference in new issue
Block a user