Compare commits

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:58:36 +08:00
董宏凯 4f7f07504a Merge branch 'master-nj' into 'master'
fix(buttons): 行动分析宿主路由支持切换任务id/行动id

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

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

No files matched your search

+2
View File
@@ -100,6 +100,8 @@ offline-backend-20260512/backend/tests/*
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep strategy-selection built-in agent seed and safety-contract regressions versioned.
+3
View File
@@ -59,6 +59,9 @@
# 简介模式左侧菜单类型:与菜单管理页「简介模式」的类型 A / B 对应。两套网页分别填 A 或 B。
# 不填或留空时按类型 A,继续使用原来的简介菜单数据。
# VITE_COMPACT_MENU_TYPE=A
# 问答页「极速」模式:true=在模式菜单显示并直连大模型;false 或未设=隐藏。
# 部署后优先改 dist/runtime-config.js 的 VITE_TURBO_MODE_VISIBLE,刷新即可。
# VITE_TURBO_MODE_VISIBLE=false
# VITE_GITHUB_OAUTH_TOKEN=
# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)---
-35
View File
@@ -1,35 +0,0 @@
# 项目空间
入口:全量与简洁导航中的「项目空间」,路由 `/page/workspace/projects`。
## 使用流程
1. 新建项目,填写名称,在问答、策略智能、方案生成中选择一种模式。
2. 选择项目默认智能体,填写目标、约束等项目说明。新建项目对话时仍可换用另一个可访问的智能体。
3. 创建后进入嵌入式问答工作区,左侧保留普通聊天记录;项目对话也会出现在聊天记录中并返回所属项目。
4. 方案生成可从「工作流配置 · 报告结构」选择、启停、编辑、删除结构,选择后自动填充项目报告结构配置;也可直接编辑大纲及固定/弹性约束。
5. 策略智能项目保存默认 Strategy Lens;每轮依次进行问题重构、各 Lens 独立分析、策略辩论和方案生成,并分为「意图理解」「多视角策略分析」「完整策略方案」三栏。右栏以文档形式给出执行摘要、目标原则、分阶段行动、资源配置、风险应对、评估复盘、形成依据与备选策略切换条件;原始对话和引用仍可切换查看。
6. 策略智能对话底部提供改变约束、增删视角、重新推演和挑战当前策略快捷动作;项目主页面可直接添加或删除 Lens。
模式在创建后不可修改,前后端都不提供修改入口。项目、对话归属和智能体访问权限在服务端校验;方案结构及每条对话所选智能体也以服务端配置为准。
## 代码与运行
- `src/pages/AnalysisProjectsPage.tsx`:项目列表、新建、设置、Lens 配置、历史与工作台布局。
- `src/pages/analysis-projects/ProjectConversation.tsx`:现有聊天、研究进度、方案画布的项目级组合。
- `src/core/analysis-projects/api.ts`:Gateway 项目 API 与按账号隔离的缓存。
- 后端:`/api/analysis-projects`,对话仍通过现有 `/api/threads/{thread_id}/runs/*` 流式接口发送;服务端从不可变归属读取项目配置。
启动新版 Gateway 后自动创建项目表。生产持久化依赖已配置的数据库;memory 模式中的项目随服务重启丢失。前端沿用仓库的 Vite 开发与构建命令。
## 验证范围
后端回归命令:在 `offline-backend-20260512/backend` 执行
```shell
python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q
```
涵盖内存与 SQLite 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
+5
View File
@@ -151,6 +151,11 @@ window.__CM_RUNTIME_CONFIG__ = {
// GitHub OAuth Token;不使用 GitHub 相关能力时留空。
VITE_GITHUB_OAUTH_TOKEN: "",
// 是否在问答输入框「模式」菜单显示「极速」:"true" 显示,"false" 隐藏。
// 默认关闭。开启后可直连大模型流式问答,不走智能体工具与技能。
// 部署后改 dist/runtime-config.js 再刷新页面即可,无需重新打包。
VITE_TURBO_MODE_VISIBLE: "true",
// 是否在智能体管理页显示「舆情分析」类型:"true" 显示,"false" 隐藏。
// 关闭后,该智能体的专用对话地址也会自动跳回智能体管理页。
VITE_SENTIMENT_AGENT_VISIBLE: "true",
@@ -152,7 +152,6 @@ export function AgentDetailPanel({
positionName,
skillsIcon,
skillsTitle,
belowSkillsSlot,
showCommonQuestions = false,
jobResponsibilities,
panelTitle,
@@ -182,8 +181,6 @@ export function AgentDetailPanel({
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean;
/**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -943,9 +940,6 @@ export function AgentDetailPanel({
/>
</Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div>
@@ -83,12 +83,10 @@ const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
writingMode,
}) => {
const {
isMock,
@@ -116,7 +114,7 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
@@ -2,10 +2,12 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
FolderTreeIcon,
GaugeIcon,
GraduationCapIcon,
LightbulbIcon,
PaperclipIcon,
@@ -67,6 +69,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads";
import { isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
@@ -142,6 +145,7 @@ export function InputBox({
hideAdminRecommendedQuestions,
compact,
hideContextTools,
hideMemoryTool = true,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -149,9 +153,14 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean;
context: Omit<
AgentThreadContext,
@@ -160,6 +169,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
};
extraHeader?: React.ReactNode;
isNewThread?: boolean;
@@ -176,6 +186,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
memory_injection_enabled?: boolean;
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -214,7 +225,11 @@ export function InputBox({
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const turboModeVisible = isTurboModeVisible();
const directChatActive =
turboModeVisible && context.execution_profile === "direct";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const bypassAgentActive = frontendRagActive || directChatActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
@@ -244,11 +259,37 @@ export function InputBox({
recommendedQuestions.length > 0;
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
// Frontend-only execution profiles are not model options.
// Do not let the normal model fallback clear them on every render.
if (!turboModeVisible && context.execution_profile === "direct") {
onContextChange?.({
...context,
execution_profile: undefined,
});
return;
}
if (multiRoundRagActive) {
return;
}
if (directChatActive) {
if (models.length === 0) {
return;
}
const currentModel = models.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!;
if (
context.model_name === fallbackModel.name &&
context.execution_profile === "direct"
) {
return;
}
onContextChange?.({
...context,
model_name: fallbackModel.name,
execution_profile: "direct",
});
return;
}
if (modelOptions.length === 0) {
return;
}
@@ -272,7 +313,7 @@ export function InputBox({
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
}, [context, directChatActive, modelOptions, models, multiRoundRagActive, onContextChange, turboModeVisible]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
@@ -305,7 +346,10 @@ export function InputBox({
model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort,
execution_profile: model.execution_profile,
execution_profile:
context.execution_profile === "direct"
? "direct"
: model.execution_profile,
});
setModelDialogOpen(false);
},
@@ -337,6 +381,16 @@ export function InputBox({
[onContextChange, context, models, supportThinking],
);
const handleDirectChatSelect = useCallback(() => {
const ordinaryModel =
models.find((model) => model.name === context.model_name) ?? models[0];
onContextChange?.({
...context,
model_name: ordinaryModel?.name ?? context.model_name,
execution_profile: "direct",
});
}, [context, models, onContextChange]);
const handleSingleRoundRagSelect = useCallback(() => {
const ragModel =
extraModels.find((model) => model.name === context.model_name) ??
@@ -388,6 +442,14 @@ export function InputBox({
});
}, [artifactDirectoryModeEnabled, setLocalSettings]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => {
@@ -732,7 +794,7 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
{!frontendRagActive && (
{!bypassAgentActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
@@ -741,7 +803,7 @@ export function InputBox({
)}
/>
)}
{!frontendRagActive && !hideContextTools && <Tooltip
{!bypassAgentActive && !hideContextTools && <Tooltip
content={
context.writing_mode
? "写作模式已开启:报告由深度研究管线按「通用分析结构」撰写,优先使用对话中已检索的资料"
@@ -767,7 +829,7 @@ export function InputBox({
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
{!bypassAgentActive && <Tooltip
content={
artifactDirectoryModeEnabled
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
@@ -796,7 +858,33 @@ export function InputBox({
<span className="text-xs font-normal">文件</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
{!bypassAgentActive && !hideContextTools && !hideMemoryTool && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
: "记忆注入已关闭:本次对话不注入记忆"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
memoryInjectionEnabled
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
: compact
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
: "border-0 bg-transparent 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",
)}
onClick={handleMemoryInjectionToggle}
>
{memoryInjectionEnabled && (
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
)}
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{referenceModeEntryEnabled && !bypassAgentActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -826,7 +914,7 @@ export function InputBox({
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && <Tooltip
{!bypassAgentActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -866,7 +954,9 @@ export function InputBox({
: "flash"
}
tooltipContent={
singleRoundRagActive
directChatActive
? `${t.inputBox.turboMode}:${t.inputBox.turboModeDescription}`
: singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
@@ -878,7 +968,9 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(frontendRagActive
(directChatActive
? "border-sky-200 bg-sky-50/60 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-200"
: frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -890,7 +982,9 @@ export function InputBox({
)}
>
<div>
{frontendRagActive ? (
{directChatActive ? (
<GaugeIcon className="size-3" />
) : frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
@@ -905,10 +999,14 @@ export function InputBox({
<div
className={cn(
"text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "",
context.mode === "ultra" && !directChatActive && !frontendRagActive
? "golden-text"
: "",
)}
>
{frontendRagActive
{directChatActive
? t.inputBox.turboMode
: frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
@@ -926,9 +1024,39 @@ export function InputBox({
{t.inputBox.mode}
</DropdownMenuLabel>
<PromptInputActionMenu>
{turboModeVisible && (
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "flash"
directChatActive
? "bg-sky-50 text-sky-800 hover:bg-sky-100 focus:bg-sky-100 dark:bg-sky-950/40 dark:text-sky-200 dark:hover:bg-sky-950/60 dark:focus:bg-sky-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleDirectChatSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<GaugeIcon
className={cn(
"mr-2 size-4",
directChatActive && "text-sky-700 dark:text-sky-200",
)}
/>
{t.inputBox.turboMode}
</div>
<div className="pl-7 text-xs">
{t.inputBox.turboModeDescription}
</div>
</div>
{directChatActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
)}
<PromptInputActionMenuItem
className={cn(
!bypassAgentActive && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -939,7 +1067,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "flash" &&
!bypassAgentActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -949,7 +1077,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "flash" ? (
{!bypassAgentActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -958,7 +1086,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "thinking"
!bypassAgentActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -969,7 +1097,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "thinking" &&
!bypassAgentActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -979,7 +1107,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "thinking" ? (
{!bypassAgentActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -988,7 +1116,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "pro"
!bypassAgentActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -999,7 +1127,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!frontendRagActive && context.mode === "pro" &&
!bypassAgentActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -1009,7 +1137,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "pro" ? (
{!bypassAgentActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1017,7 +1145,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!frontendRagActive && context.mode === "ultra"
!bypassAgentActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -1028,14 +1156,14 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!frontendRagActive &&
!bypassAgentActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!frontendRagActive &&
!bypassAgentActive &&
context.mode === "ultra" &&
"golden-text",
)}
@@ -1047,7 +1175,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!frontendRagActive && context.mode === "ultra" ? (
{!bypassAgentActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1118,7 +1246,7 @@ export function InputBox({
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
{!bypassAgentActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1236,7 +1364,7 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{!frontendRagActive && agentSelector && (
{!bypassAgentActive && agentSelector && (
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1244,8 +1372,8 @@ export function InputBox({
compact={compact}
/>
)}
{!frontendRagActive && knowledgeSelector}
{!frontendRagActive && promptPrefix && (
{!bypassAgentActive && knowledgeSelector}
{!bypassAgentActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -373,8 +373,6 @@ export function LlmWikiKnowledgeSelector({
readOnly = false,
className,
label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover",
}: {
value: string[];
@@ -389,8 +387,6 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean;
className?: string;
label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
@@ -478,10 +474,10 @@ export function LlmWikiKnowledgeSelector({
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />{panelTitle}
<Database className="size-4 text-primary" />绑定知识空间
</div>
<p className="mt-1 text-xs text-muted-foreground">
{panelDescription}
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
@@ -1,67 +0,0 @@
"use client";
import { BrainIcon, Loader2Icon } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
useMemoryRuntimeSettings,
useUpdateMemoryRuntimeSettings,
} from "@/core/system-settings/hooks";
export function MemoryRuntimeSettingsPage() {
const { data, isLoading, isError } = useMemoryRuntimeSettings();
const update = useUpdateMemoryRuntimeSettings();
const enabled = data?.enabled ?? false;
const handleChange = async (checked: boolean) => {
try {
await update.mutateAsync({ enabled: checked });
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
return (
<section className="space-y-6">
<div className="space-y-1">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<BrainIcon className="size-5" />
问答记忆
</h3>
<p className="text-muted-foreground text-sm">
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
</p>
</div>
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
<div className="space-y-1">
<div className="font-medium">启用问答记忆</div>
<p className="text-muted-foreground text-sm">
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
</p>
</div>
{isLoading ? (
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
) : (
<Switch
checked={enabled}
disabled={isError || update.isPending}
onCheckedChange={handleChange}
aria-label="启用问答记忆"
/>
)}
</div>
{isError && (
<p className="text-destructive text-sm">
无法读取记忆开关,系统将按关闭处理。
</p>
)}
<p className="text-muted-foreground text-xs">
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
</p>
</section>
);
}
@@ -1,6 +1,6 @@
"use client";
import { BellIcon, BrainIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -61,9 +61,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
// },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }]
: []),
// 记忆已移至侧边栏入口 (/page/workspace/memory)
// {
// id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
@@ -129,9 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)}
{activeSection === "memory" && isAdmin && (
<MemoryRuntimeSettingsPage />
)}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />}
@@ -54,13 +54,6 @@ export type WorkspaceCompactNavItem = MenuItem & {
};
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
{
id: "workspace-projects",
label: "项目空间",
icon: BookOpen,
path: "/page/workspace/projects",
match: (pathname) => pathname.startsWith("/page/workspace/projects"),
},
{
id: "workspace-new-chat",
label: "新对话",
@@ -105,6 +98,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
},
{
id: "workspace-memory",
label: "记忆",
icon: BrainIcon,
path: "/page/workspace/memory",
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
},
{
id: "workspace-ai-writing",
label: "AI写作",
@@ -35,7 +35,6 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa =
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/chats");
@@ -1,137 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/core/api";
import { getStoredAuth } from "@/core/auth";
import { getBackendBaseURL } from "@/core/config";
export type ProjectMode = "qa" | "strategy" | "solution";
export type ProjectReportConfig = {
custom_outline: string;
structure_mode: "fixed" | "adaptive";
retrieval_directions: string[];
retrieval_skills: string[];
};
export type ProjectStrategyConfig = { lenses: string[] };
export const DEFAULT_STRATEGY_LENSES = [
"增长视角",
"风险视角",
"竞争视角",
"资源视角",
"长期价值视角",
] as const;
export type ProjectFields = {
name: string;
background: string;
knowledge_base_ids: string[];
assistant_knowledge_base_ids: string[];
agent_id: string | null;
report_config: ProjectReportConfig;
strategy_config: ProjectStrategyConfig;
};
export type AnalysisProject = ProjectFields & {
id: string;
mode: ProjectMode;
created_at: string;
updated_at: string;
solution_template: { id: string; name: string; outline: string } | null;
};
export type ProjectThread = {
thread_id: string;
title?: string;
status: string;
updated_at: string;
metadata?: Record<string, unknown>;
};
export const PROJECT_MODES = {
qa: { name: "问答模式", description: "围绕项目资料问答、解释与总结" },
strategy: {
name: "策略智能模式",
description: "选择分析视角,开展多视角推演、策略辩论与综合决策",
},
solution: {
name: "方案生成模式",
description: "选择报告结构,沿用研究管线生成方案",
},
} as const;
export const PROJECT_OUTLINE = [
"背景与依据",
"目标与约束",
"实施路径与重点任务",
"资源配置与时间计划",
"风险与应对措施",
"评估指标与参考依据",
];
export const projectKeys = () =>
["analysis-projects", getStoredAuth()?.user_id ?? "default"] as const;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/analysis-projects${path}`,
{
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
},
);
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(
typeof error.detail === "string"
? error.detail
: `项目操作失败(${response.status})`,
);
}
return response.json() as Promise<T>;
}
export function useAnalysisProjects() {
return useQuery({
queryKey: projectKeys(),
queryFn: () => request<{ projects: AnalysisProject[] }>(""),
});
}
export function useAnalysisProject(id: string) {
return useQuery({
queryKey: [...projectKeys(), id],
queryFn: () => request<AnalysisProject>(`/${encodeURIComponent(id)}`),
enabled: !!id,
});
}
export function useProjectThreads(id: string) {
return useQuery({
queryKey: [...projectKeys(), id, "threads"],
queryFn: () =>
request<{ threads: ProjectThread[] }>(
`/${encodeURIComponent(id)}/threads`,
),
enabled: !!id,
refetchInterval: 15000,
});
}
export function useProjectThreadAccess(projectId: string, threadId?: string) {
return useQuery({
queryKey: [...projectKeys(), projectId, "thread", threadId],
queryFn: () =>
request<ProjectThread>(
`/${encodeURIComponent(projectId)}/threads/${encodeURIComponent(threadId!)}`,
),
enabled: !!threadId,
retry: false,
});
}
export const createAnalysisProject = (
fields: ProjectFields & { mode: ProjectMode },
) =>
request<AnalysisProject>("", {
method: "POST",
body: JSON.stringify(fields),
});
export const updateAnalysisProject = (id: string, fields: ProjectFields) =>
request<AnalysisProject>(`/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(fields),
});
export const createProjectThread = (id: string, agentId?: string | null) =>
request<ProjectThread>(`/${encodeURIComponent(id)}/threads`, {
method: "POST",
body: JSON.stringify(agentId === undefined ? {} : { agent_id: agentId }),
});
@@ -1,141 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
export const STRATEGY_COLUMNS = [
"意图理解",
"多视角策略分析",
"完整策略方案",
] as const;
const STRATEGY_HEADING_INDEX: Record<string, number> = {
意图理解: 0,
意图理解与分析角度: 0,
多视角策略分析: 1,
推荐策略: 1,
完整策略方案: 2,
综合结果: 2,
结论: 2,
};
/** Parse only user-facing text. Never render tool payloads or reasoning fields. */
export function splitStrategySections(text: string): [string, string, string] {
const parts: string[][] = [[], [], []];
let section = 0;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading =
!fence &&
line.match(
/^\s*#{1,2}\s+(?:[一二三123][、..]\s*)?(意图理解与分析角度|意图理解|多视角策略分析|推荐策略|完整策略方案|综合结果|结论)\s*$/,
);
if (heading) {
section = STRATEGY_HEADING_INDEX[heading[1]!] ?? section;
continue;
}
parts[section]!.push(line);
}
return parts.map((lines) => lines.join("\n").trim()) as [
string,
string,
string,
];
}
export type StrategySubsection = { title: string; content: string };
export type StrategyClarification = {
question: string;
context?: string;
options: { id: string; label: string; description?: string }[];
allow_custom: boolean;
};
export function pendingStrategyClarification(
messages: Message[],
): StrategyClarification | null {
let pending: StrategyClarification | null = null;
for (const message of messages) {
const raw = message.additional_kwargs?.clarification;
if (raw && typeof raw === "object") {
const data = raw as Record<string, unknown>;
const question = typeof data.question === "string" ? data.question : "";
if (!question) continue;
pending = {
question,
context: typeof data.context === "string" ? data.context : undefined,
options: Array.isArray(data.options)
? data.options.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const option = item as Record<string, unknown>;
if (typeof option.label !== "string") return [];
return [{
id: typeof option.id === "string" ? option.id : option.label,
label: option.label,
description:
typeof option.description === "string"
? option.description
: undefined,
}];
})
: [],
allow_custom: data.allow_custom !== false,
};
} else if (pending && message.type === "human") {
pending = null;
}
}
return pending;
}
export function splitStrategySubsections(text: string): StrategySubsection[] {
const sections: StrategySubsection[] = [];
let current: StrategySubsection | null = null;
let fence = false;
for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
if (/^\s*```/.test(line)) fence = !fence;
const heading = !fence && line.match(/^\s*###\s+(.+?)\s*$/);
if (heading) {
current = { title: heading[1]!, content: "" };
sections.push(current);
} else if (current) {
current.content += `${current.content ? "\n" : ""}${line}`;
}
}
return sections.map((item) => ({ ...item, content: item.content.trim() }));
}
export function strategyRounds(messages: Message[]) {
const rounds: { id: string; question: string; answer: string }[] = [];
for (const message of messages) {
if (
message.additional_kwargs?.hide_from_ui ||
!["human", "ai"].includes(message.type)
)
continue;
const text =
typeof message.content === "string"
? message.content
: message.content
.filter(
(item): item is Extract<typeof item, { type: "text" }> =>
item.type === "text" &&
"text" in item &&
typeof item.text === "string",
)
.map((item) => item.text)
.join("\n");
if (message.type === "human") {
if (text.trim())
rounds.push({
id: message.id || `round-${rounds.length}`,
question: text,
answer: "",
});
} else if (text.trim()) {
if (!rounds.length)
rounds.push({
id: message.id || "round-0",
question: "策略分析",
answer: "",
});
rounds[rounds.length - 1]!.answer +=
`${rounds[rounds.length - 1]!.answer ? "\n\n" : ""}${text}`;
}
}
return rounds;
}
@@ -96,6 +96,9 @@ export const enUS: Translations = {
mode: "Mode",
flashMode: "Flash",
flashModeDescription: "Fast and efficient, but may not be accurate",
turboMode: "Turbo",
turboModeDescription:
"Talk to the model directly with no agent tools or skills, for lower first-token latency",
reasoningMode: "Reasoning",
reasoningModeDescription:
"Reasoning before action, balance between time and accuracy",
@@ -91,6 +91,8 @@ export interface Translations {
mode: string;
flashMode: string;
flashModeDescription: string;
turboMode: string;
turboModeDescription: string;
reasoningMode: string;
reasoningModeDescription: string;
proMode: string;
@@ -95,6 +95,8 @@ export const zhCN: Translations = {
mode: "模式",
flashMode: "快速",
flashModeDescription: "快速且高效的完成任务,但可能不够精准",
turboMode: "极速",
turboModeDescription: "直连大模型流式问答,不走智能体工具与技能,首字更快",
reasoningMode: "思考",
reasoningModeDescription: "思考后再行动,在时间与准确性之间取得平衡",
proMode: "专业问答",
+9 -11
View File
@@ -364,17 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
if (message.additional_kwargs) {
const reasoningContent = message.additional_kwargs.reasoning_content;
if (typeof reasoningContent === "string") {
return reasoningContent;
}
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
const reasoning = message.additional_kwargs.reasoning;
if (typeof reasoning === "string") {
return reasoning;
}
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
@@ -393,7 +392,6 @@ export function removeReasoningContentFromMessage(message: Message) {
return;
}
delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
}
export function extractURLFromImageURLContent(
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Model {
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** 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 {
@@ -73,7 +73,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle,
children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
{ id: "analysis-projects", label: "项目空间", icon: Layout, path: "/page/workspace/projects" },
{ id: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{
id: "multi-agent-roundtable",
@@ -601,48 +601,6 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>;
}
export interface MemoryRuntimeSettings {
enabled: boolean;
}
export async function loadMemoryRuntimeSettings(): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
);
if (!response.ok) {
throw new Error(`Failed to load memory runtime setting: ${response.status}`);
}
return (await response.json()) as MemoryRuntimeSettings;
}
export async function updateMemoryRuntimeSettings(
body: MemoryRuntimeSettings,
): Promise<MemoryRuntimeSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/memory`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update memory runtime setting" }));
throw new Error(detail.detail ?? "Failed to update memory runtime setting");
}
const data = (await response.json()) as MemoryRuntimeSettings;
writeYLog({
optType: OptType.edit,
optDetail: "CM助手-全量-系统设置-问答记忆开关",
url: "/api/system-settings/memory",
reqParam: body,
returnParam: data,
});
return data;
}
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`,
@@ -8,7 +8,6 @@ import {
loadCitationDisplaySettings,
loadLeaderboardSettings,
loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings,
@@ -20,7 +19,6 @@ import {
updateCitationDisplaySettings,
updateLeaderboardSettings,
updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings,
@@ -31,7 +29,6 @@ import {
type CitationDisplayUpdate,
type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate,
@@ -50,7 +47,6 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] as const;
const MEMORY_RUNTIME_KEY = ["systemSettings", "memoryRuntime"] as const;
const PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] as const;
@@ -115,26 +111,6 @@ export function useUpdatePrivateSquareSettings() {
});
}
export function useMemoryRuntimeSettings() {
return useQuery({
queryKey: MEMORY_RUNTIME_KEY,
queryFn: () => loadMemoryRuntimeSettings(),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateMemoryRuntimeSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: MemoryRuntimeSettings) =>
updateMemoryRuntimeSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(MEMORY_RUNTIME_KEY, data);
},
});
}
/** The configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() {
+3 -7
View File
@@ -887,13 +887,6 @@ export function useThreadStream({
// made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) {
const projectHash = typeof window !== "undefined" ? window.location.hash : "";
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
if (projectMatch) {
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
navigate("/page/workspace/projects", { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
@@ -1586,6 +1579,7 @@ export function useThreadStream({
context: {
...mergedContext,
thinking_enabled: mergedContext.mode !== "flash",
thinking_force_disabled: mergedContext.mode === "flash",
is_plan_mode:
mergedContext.mode === "pro" || mergedContext.mode === "ultra",
subagent_enabled: mergedContext.mode === "ultra",
@@ -1598,6 +1592,8 @@ export function useThreadStream({
: mergedContext.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled,
thread_id: threadId,
+5 -2
View File
@@ -14,11 +14,14 @@ export interface AgentThreadContext extends Record<string, unknown> {
thread_id: string;
model_name: string | undefined;
thinking_enabled: boolean;
/** Flash mode: force vLLM/Qwen ``enable_thinking=false`` even without a per-model disable shape. */
thinking_force_disabled?: boolean;
is_plan_mode: boolean;
subagent_enabled: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean;
@@ -28,8 +31,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
llmwiki_knowledge_base_ids?: string[];
/** Assistant knowledge-base ids selected for this conversation. */
assistant_knowledge_base_ids?: string[];
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
/** Frontend-only execution paths that never enter Lead Agent (RAG / 极速直连). */
execution_profile?: "single_round_rag" | "multi_round_rag" | "direct";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
@@ -0,0 +1,312 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { readRuntimeFlag } from "@/env";
import { createClientId } from "@/lib/client-id";
import { textOfMessage } from "./utils";
const STORAGE_PREFIX = "workspace.direct-chat.";
const MAX_HISTORY_MESSAGES = 20;
export function isDirectChatProfile(context: unknown): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile === "direct"
);
}
/** 是否在模式菜单展示「极速」。仅 runtime-config.js 显式为 "true" 时开启,默认关闭。 */
export function isTurboModeVisible(): boolean {
return readRuntimeFlag("VITE_TURBO_MODE_VISIBLE");
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException
? error.name === "AbortError"
: error instanceof Error && error.name === "AbortError";
}
function historyFromMessages(messages: Message[]): Array<{
role: "user" | "assistant";
content: string;
}> {
const history: Array<{ role: "user" | "assistant"; content: string }> = [];
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
const content = textOfMessage(message)?.trim();
if (!content) continue;
history.push({
role: message.type === "human" ? "user" : "assistant",
content,
});
}
return history.slice(-MAX_HISTORY_MESSAGES);
}
function parseSseBlocks(buffer: string): {
events: Array<{ event?: string; data: string }>;
rest: string;
} {
const parts = buffer.split("\n\n");
const rest = parts.pop() ?? "";
const events: Array<{ event?: string; data: string }> = [];
for (const part of parts) {
let event: string | undefined;
const dataLines: string[] = [];
for (const line of part.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length > 0) {
events.push({ event, data: dataLines.join("\n") });
}
}
return { events, rest };
}
async function readErrorDetail(response: Response): Promise<string> {
try {
const payload: unknown = await response.json();
if (
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
) {
return (payload as { detail: string }).detail;
}
} catch {
// Fall through to status text.
}
return `极速问答失败(${response.status})`;
}
/**
* Frontend-only adapter that talks to Gateway ``POST /api/direct-chat/stream``.
* It never submits a LangGraph run or touches the lead-agent stack.
*/
export function useDirectChatMessages(threadId: string | undefined) {
const [messages, setMessages] = useState<Message[]>(() =>
readStoredMessages(threadId ?? ""),
);
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId ?? "");
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName?: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createClientId("direct-human");
const assistantId = createClientId("direct-ai");
const clientTs = new Date().toISOString();
const history = historyFromMessages(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, direct_chat: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { direct_chat: true },
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
initialAssistant,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
let answer = "";
const refreshAssistant = (content: string) => {
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
};
try {
const response = await apiFetch(
`${getBackendBaseURL()}/api/direct-chat/stream`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model_name: modelName,
messages: [...history, { role: "user", content: question }],
}),
},
);
if (!response.ok) {
throw new Error(await readErrorDetail(response));
}
if (!response.body) {
throw new Error("极速问答未返回数据流。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseBlocks(buffer);
buffer = parsed.rest;
for (const block of parsed.events) {
let payload: unknown = null;
try {
payload = JSON.parse(block.data);
} catch {
continue;
}
if (block.event === "error") {
const detail =
payload &&
typeof payload === "object" &&
"detail" in payload &&
typeof (payload as { detail: unknown }).detail === "string"
? (payload as { detail: string }).detail
: "极速问答失败。";
throw new Error(detail);
}
if (
block.event === "delta" &&
payload &&
typeof payload === "object" &&
"text" in payload &&
typeof (payload as { text: unknown }).text === "string"
) {
answer += (payload as { text: string }).text;
refreshAssistant(answer);
}
if (
block.event === "result" &&
payload &&
typeof payload === "object" &&
"answer" in payload &&
typeof (payload as { answer: unknown }).answer === "string"
) {
answer = (payload as { answer: string }).answer;
refreshAssistant(answer);
}
}
}
if (!answer) {
throw new Error("模型未返回任何文本。");
}
} catch (caught) {
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "极速问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answer || message,
additional_kwargs: {
...item.additional_kwargs,
direct_chat: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
+6 -24
View File
@@ -45,17 +45,10 @@ export function pathOfThread(
base = "/page/workspace",
) {
const threadId = typeof thread === "string" ? thread : thread.thread_id;
const projectId =
typeof thread === "string"
? undefined
: thread.metadata?.analysis_project_id;
if (typeof projectId === "string" && projectId) {
return `${base}/projects/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(threadId)}`;
}
const agentRef =
typeof thread === "string"
? pickAgentRef(context, null)
: (agentRefOfThread(thread) ?? pickAgentRef(context, null));
: agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -127,9 +120,7 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null;
}): boolean {
const threadType = thread.metadata?.thread_type;
return (
threadType === "writing_setup" || threadType === "report_structure_setup"
);
return threadType === "writing_setup" || threadType === "report_structure_setup";
}
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -139,10 +130,7 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata;
if (metadata?.system === true) return true;
const threadType = metadata?.thread_type;
return (
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
}
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -170,15 +158,11 @@ export function listPathOfThread(thread: AgentThread): string {
}
export function isThreadPinned(thread: AgentThread): boolean {
return (
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
}
export function isThreadFavorite(thread: AgentThread): boolean {
return (
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
}
/**
@@ -195,9 +179,7 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest];
}
export function sanitizeThreadTitleForDisplay(
title: string | null | undefined,
) {
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
if (!title) return "Untitled";
const cleaned = title
@@ -165,10 +165,7 @@ async function externalTaskError(response: Response, fallback: string): Promise<
}
function findProcessReportId(payload: unknown): string {
if (payload == null) return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
if (!payload || typeof payload !== "object") return "";
const source = payload as Record<string, unknown>;
const direct = firstText(
source.id,
@@ -181,10 +178,9 @@ function findProcessReportId(payload: unknown): string {
}
/**
* Create the external task system's process report **and submit it in one
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` —
* `data` is the new record id. The local gateway stores that id so a refresh
* can still reconcile the same report instead of creating a duplicate.
* Create the external task system's process report in its unsubmitted state.
* The local gateway stores the returned id so a refresh can later submit the
* same report instead of creating a duplicate one.
*/
export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput,
@@ -200,7 +196,7 @@ export async function createEightBfTaskProcessReport(
return { id };
}
/** Flip an already-registered (submitStatus=0) process report to submitted. Only used to resume legacy ledger rows; the normal path submits directly on create. */
/** Submit an already-registered process report to the external task system. */
export async function updateEightBfTaskProcessReport(
reportId: string,
input: EightBfTaskProcessReportInput,
@@ -32,7 +32,6 @@ import {
} from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent";
import { updateChain } from "@/roundtable-planning/api/chains";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -47,8 +46,6 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string;
modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void;
@@ -87,7 +84,6 @@ export function EightBfStep1Panel({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
selectedModel,
modelOptions,
onSelectedModelChange,
@@ -157,7 +153,6 @@ export function EightBfStep1Panel({
chain={chain}
canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds}
@@ -304,7 +299,6 @@ function EightBfAgentWorkspace({
chain,
canConfigureChain,
onConfigureChain,
onChainUpdated,
onCollapse,
step2Dialogues,
step2ThreadIds,
@@ -316,7 +310,6 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null;
canConfigureChain: boolean;
onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void;
step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null;
@@ -455,13 +448,6 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions
panelTitle="智能体配置"
embedded
@@ -626,82 +612,3 @@ function arrayValue(...values: unknown[]): string[] {
}
return [];
}
/**
* 「智能体配置」面板里、技能列表下方的「产物提交 position」编辑器。
*
* 直接读写该席位在业务链上配置的 artifact_position(保存即 PUT 业务链的 seats),
* 用户无需去业务链编辑页找链配 position,也不易配错链。只有业务链 owner / 管理员
* 能保存(后端校验);提交产物时按席位取这里保存的值,留空按 0。
*/
function SeatArtifactPositionEditor({
chain,
seat,
onUpdated,
}: {
chain: BusinessChain;
seat: ChainSeat;
onUpdated?: (chain: BusinessChain) => void;
}) {
const [value, setValue] = useState(seat.artifact_position?.toString() ?? "");
const [saving, setSaving] = useState(false);
// 席位切换 / 外部链数据刷新时,回填链上的当前值。
useEffect(() => {
setValue(seat.artifact_position?.toString() ?? "");
}, [seat.agent_id, seat.artifact_position]);
const trimmed = value.trim();
const parsed = trimmed === "" ? null : Number.parseInt(trimmed, 10);
const nextValue = parsed !== null && Number.isFinite(parsed) ? parsed : null;
const dirty = nextValue !== (seat.artifact_position ?? null);
const handleSave = async () => {
if (saving || !dirty) return;
setSaving(true);
try {
const seats = chain.seats.map((s) =>
s.agent_id === seat.agent_id ? { ...s, artifact_position: nextValue } : s,
);
const updated = await updateChain(chain.id, { seats });
if (!updated) throw new Error("业务链已不存在,可能被删除");
onUpdated?.(updated);
toast.success(`已保存「${seat.name || seat.agent_id}」的产物 position`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存产物 position 失败");
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 rounded-lg border border-[#2b8ffe]/30 bg-[#042b59]/60 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#aaceff]">产物提交 position</span>
<div className="flex items-center gap-1.5">
<input
type="number"
inputMode="numeric"
value={value}
placeholder="0"
aria-label="产物提交 position"
onChange={(event) => setValue(event.target.value)}
className="h-7 w-20 rounded-md border border-[#2b8ffe]/40 bg-[#032680]/60 px-2 text-xs font-semibold tabular-nums text-[#e8f2ff] outline-none focus:border-[#2b8ffe] [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
disabled={!dirty || saving}
onClick={() => void handleSave()}
className="inline-flex h-7 items-center gap-1 rounded-md border border-[#2b8ffe]/50 bg-[#0a3c8f]/70 px-2.5 text-xs font-semibold text-[#e8f2ff] transition hover:bg-[#0a3c8f] disabled:cursor-not-allowed disabled:opacity-50"
>
{saving && <Loader2 className="size-3 animate-spin" />}
保存
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-[#8aa0c4]">
该席位提交过程产物时传给任务系统的 position 值,留空按 0 提交。保存会写入业务链「{chain.title}」,仅链所有者 / 管理员可保存。
</p>
</div>
);
}
@@ -88,50 +88,29 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries),
[task, taskDictionaries],
);
// 席位「产物提交 position」有效取值映射(提交产物时按席位智能体取值):
// 1) 绑定链的席位 —— 键收录小写 agent_id + 小写席位名(会话侧席位标识在不同
// 表面可能以 id 或名称出现,双键匹配避免查不到);
// 2) 其它公共链上同一 agent_id 配置的值(历史会话的席位可能来自切换绑定前的
// 旧链,agent_id 跨链唯一指向同一智能体,可安全回退;**名称不跨链匹配**,
// 避免不同链的同名席位互相误配)。
// 绑定链优先(先写入),未配置的席位不进 map(提交时回退默认 0)。
const seatArtifactPositions = useMemo(() => {
const map: Record<string, number> = {};
const put = (key: string, value: number) => {
const normalized = key.trim().toLowerCase();
if (normalized && !(normalized in map)) map[normalized] = value;
};
// 席位「产物提交 position」映射:业务链里逐席位配置的 artifact_position(agent_id → 值)。
// 提交产物时按产出该产物的席位智能体取值;未配置的席位不进 map(提交时回退默认 0)。
const artifactPositionByAgent = useMemo(() => {
const map = new Map<string, number>();
for (const seat of configuredChain?.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
if (seat.name?.trim()) put(seat.name, seat.artifact_position);
}
}
for (const chain of chains) {
if (chain.id === configuredChain?.id) continue;
for (const seat of chain.seats ?? []) {
if (typeof seat.artifact_position === "number" && Number.isFinite(seat.artifact_position)) {
put(seat.agent_id, seat.artifact_position);
}
map.set(seat.agent_id, seat.artifact_position);
}
}
return map;
}, [chains, configuredChain]);
}, [configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? {
taskId,
position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
return agentId ? artifactPositionByAgent.get(agentId) ?? null : null;
},
createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport,
} : undefined,
[taskId, seatArtifactPositions],
[taskId, artifactPositionByAgent],
);
useEffect(() => {
@@ -235,12 +214,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = (
<>
{canConfigureChain && isSettingsOpen && (
@@ -335,7 +308,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain}
canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/>
)}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -386,8 +358,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
}
rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/>
</div>
);
+87 -8
View File
@@ -13,6 +13,7 @@ import { useLocation } from "react-router-dom";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@@ -48,6 +49,7 @@ import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings";
import { stopActiveThreadBeforeClarificationReply, stopThreadRun, useThreadStream } from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { clearResearchReportStream } from "@/core/threads/use-research-report-stream";
import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
@@ -560,9 +562,26 @@ export default function AgentChatPage({
},
});
const directChat = useDirectChatMessages(threadId);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread } = useResearchWritingFeed(threadId, thread);
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
const displayThread = useMemo(() => {
const base = researchDisplayThread;
if (directChat.messages.length === 0 && !directChat.isLoading) {
return base;
}
const messages = [...base.messages, ...directChat.messages];
return {
...base,
values: { ...base.values, messages },
messages,
isLoading: base.isLoading || directChat.isLoading,
} as typeof base;
}, [directChat.isLoading, directChat.messages, researchDisplayThread]);
// ── agentfx「入库」协助卡 ──────────────────────────────────────────────────
// 任务工作区(taskBind)里,内置研判智能体写出的 task-reports.json 产物落地且
@@ -743,6 +762,62 @@ export default function AgentChatPage({
const handleSubmit = useCallback(
(message: PromptInputMessage, extraContext?: Record<string, unknown>) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
saveLastAgentId(agent_id);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient().threads.create({
metadata: {
agent_id,
...(Object.keys(conversationMetadata).length > 0
? conversationMetadata
: {}),
},
});
targetThreadId = created.thread_id;
knowledgeSelectionThread.current = targetThreadId;
activeThreadIdRef.current = targetThreadId;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${agentChatsBase}/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient().threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
setHasStartedConversation(true);
// Remember this agent so the new-chat landing screen pre-selects it next
// time (covers both direct agent chats and the ChatPage handoff replay).
@@ -767,7 +842,7 @@ export default function AgentChatPage({
...pageReportThemeContext,
});
},
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
[sendMessage, threadId, agent_id, settings.context.writing_mode, settings.context.model_name, writingArtifactPath, baseMode, directChat, directChatSelected, isNewThread, conversationMetadata, agentChatsBase, setThreadId, setIsNewThread],
);
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -852,8 +927,12 @@ export default function AgentChatPage({
);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
await thread.stop();
}, [thread]);
}, [directChat, thread]);
const isLandingState = isNewThread && !hasStartedConversation;
// 任务闸门面板:仅 rwfx(taskGate)在新建对话且尚未开始时,于问答页顶部展示目的树 + 覆盖确认。
@@ -867,7 +946,7 @@ export default function AgentChatPage({
const status = thread.error
? "error"
: thread.isLoading
: thread.isLoading || directChat.isLoading
? "streaming"
: "ready";
@@ -1084,7 +1163,7 @@ export default function AgentChatPage({
autoFocus
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1104,7 +1183,7 @@ export default function AgentChatPage({
pendingKnowledgeSelection.current === null &&
pendingAssistantKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
label="知识空间"
/>
)
@@ -1175,7 +1254,7 @@ export default function AgentChatPage({
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
onContextChange={(context) =>
setSettings("context", context)
}
@@ -1190,7 +1269,7 @@ export default function AgentChatPage({
assistantValue={selectedAssistantKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
onAssistantChange={changeAssistantKnowledgeSelection}
disabled={isDemoMode || knowledgeSelectionLoading}
disabled={isDemoMode || knowledgeSelectionLoading || directChat.isLoading}
readOnly
label="知识空间"
/>
@@ -1,808 +0,0 @@
import { useEffect, useState } from "react";
import {
Link,
Route,
Routes,
useLocation,
useMatch,
useNavigate,
useParams,
} from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
Bot,
FolderOpen,
Loader2,
LockKeyhole,
Plus,
Settings,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useAgentSelector } from "@/core/agents/hooks";
import { WorkflowConfigButton } from "./deep-research/WorkflowConfigDialog";
import {
createAnalysisProject,
createProjectThread,
DEFAULT_STRATEGY_LENSES,
PROJECT_MODES,
PROJECT_OUTLINE,
projectKeys,
updateAnalysisProject,
useAnalysisProject,
useAnalysisProjects,
useProjectThreadAccess,
useProjectThreads,
type AnalysisProject,
type ProjectFields,
type ProjectMode,
type ProjectReportConfig,
type ProjectStrategyConfig,
} from "@/core/analysis-projects/api";
import { cn } from "@/lib/utils";
import { ProjectConversation } from "./analysis-projects/ProjectConversation";
const BASE = "/page/workspace/projects";
const defaultReport: ProjectReportConfig = {
custom_outline: PROJECT_OUTLINE.map((s, i) => `## ${i + 1}. ${s}`).join("\n"),
structure_mode: "fixed",
retrieval_directions: [],
retrieval_skills: [],
};
const defaultStrategy: ProjectStrategyConfig = {
lenses: [...DEFAULT_STRATEGY_LENSES],
};
function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
return (
<div
role="alert"
className="m-4 rounded-lg border border-destructive/30 p-4 text-sm"
>
<p>{error instanceof Error ? error.message : "读取失败,请重试"}</p>
{retry && (
<Button variant="outline" className="mt-3" onClick={retry}>
重试
</Button>
)}
</div>
);
}
function Loading() {
return (
<div className="flex flex-1 items-center justify-center p-10">
<Loader2 className="mr-2 size-4 animate-spin" />
加载中…
</div>
);
}
function AgentSelect({
value,
onChange,
label = "选择智能体",
}: {
value: string | null;
onChange: (id: string | null) => void;
label?: string;
}) {
const { agents, isLoading, error } = useAgentSelector();
return (
<label className="flex min-w-0 flex-col gap-2 text-sm">
<span>{label}</span>
<select
aria-label={label}
className="h-9 max-w-full rounded-md border bg-background px-3 text-sm"
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
>
<option value="">通用助手</option>
{value && !agents.some((a) => a.id === value) && (
<option value={value}>所选智能体(加载中或已不可用)</option>
)}
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
{isLoading && (
<span className="text-xs text-muted-foreground">加载智能体…</span>
)}
{error && (
<span role="alert" className="text-xs text-destructive">
智能体列表加载失败,请刷新重试
</span>
)}
</label>
);
}
function ReportConfiguration({
value,
onChange,
}: {
value: ProjectReportConfig;
onChange: (next: ProjectReportConfig) => void;
}) {
return (
<section className="space-y-4 rounded-xl border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">工作流配置 · 报告结构</h2>
<p className="mt-1 text-xs text-muted-foreground">
选择结构后自动填充;结构库支持新增、启停、编辑和删除。
</p>
</div>
<WorkflowConfigButton
inlineCreate
appliedContent={value.custom_outline}
onApply={(
content,
structureMode,
retrievalDirections,
retrievalSkills,
) =>
onChange({
custom_outline: content,
structure_mode: structureMode,
retrieval_directions: retrievalDirections,
retrieval_skills: retrievalSkills,
})
}
/>
</div>
<label className="block space-y-2 text-sm">
<span>报告结构配置</span>
<Textarea
aria-label="报告结构配置"
rows={8}
value={value.custom_outline}
onChange={(e) =>
onChange({ ...value, custom_outline: e.target.value })
}
placeholder="选择报告结构,或输入 Markdown 章节大纲"
/>
</label>
<label className="flex items-center gap-3 text-sm">
<span>结构约束</span>
<select
aria-label="结构约束"
className="h-9 rounded-md border bg-background px-3"
value={value.structure_mode}
onChange={(e) =>
onChange({
...value,
structure_mode: e.target.value as "fixed" | "adaptive",
})
}
>
<option value="fixed">固定大纲</option>
<option value="adaptive">弹性大纲</option>
</select>
</label>
<p className="text-xs text-muted-foreground">
检索方向:
{value.retrieval_directions.join("、") || "由智能体根据问题确定"}
;检索技能:{value.retrieval_skills.join("、") || "沿用智能体配置"}
</p>
<p className="text-xs text-muted-foreground">
项目保存当前结构副本。结构库变更后可重新选择应用,也可直接修改本项目大纲;保存后用于下一轮方案生成。
</p>
</section>
);
}
function StrategyConfiguration({
value,
onChange,
}: {
value: ProjectStrategyConfig;
onChange: (next: ProjectStrategyConfig) => void;
}) {
const [draft, setDraft] = useState("");
const toggle = (lens: string) => {
const selected = value.lenses.includes(lens);
if (selected && value.lenses.length === 1) return;
onChange({
lenses: selected
? value.lenses.filter((item) => item !== lens)
: [...value.lenses, lens].slice(0, 10),
});
};
const add = () => {
const lens = draft.trim().replace(/视角$/, "") + "视角";
if (!draft.trim() || value.lenses.includes(lens) || value.lenses.length >= 10)
return;
onChange({ lenses: [...value.lenses, lens] });
setDraft("");
};
return (
<section className="space-y-4 rounded-xl border p-4">
<div>
<h2 className="text-sm font-medium">Strategy Lens · 默认分析视角</h2>
<p className="mt-1 text-xs text-muted-foreground">
每个视角独立形成判断与候选策略,再进入策略辩论和综合决策。至少保留一个,最多十个。
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{DEFAULT_STRATEGY_LENSES.map((lens) => (
<label
key={lens}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm",
value.lenses.includes(lens) && "border-primary bg-primary/5",
)}
>
<input
type="checkbox"
checked={value.lenses.includes(lens)}
onChange={() => toggle(lens)}
className="accent-primary"
/>
{lens}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
{value.lenses
.filter(
(lens) =>
!DEFAULT_STRATEGY_LENSES.includes(
lens as (typeof DEFAULT_STRATEGY_LENSES)[number],
),
)
.map((lens) => (
<Badge key={lens} variant="secondary" className="gap-1 py-1">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
onClick={() => toggle(lens)}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
aria-label="自定义分析视角"
maxLength={38}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
add();
}
}}
placeholder="例如:用户体验、供应链、敌方"
/>
<Button type="button" variant="outline" onClick={add}>
添加视角
</Button>
</div>
</section>
);
}
function ProjectForm({
project,
onCancel,
}: {
project?: AnalysisProject;
onCancel: () => void;
}) {
const navigate = useNavigate(),
queryClient = useQueryClient();
const [name, setName] = useState(project?.name ?? ""),
[background, setBackground] = useState(project?.background ?? "");
const [mode, setMode] = useState<ProjectMode>(project?.mode ?? "qa");
const [agentId, setAgentId] = useState<string | null>(
project?.agent_id ?? null,
);
const [report, setReport] = useState<ProjectReportConfig>({
...defaultReport,
...project?.report_config,
custom_outline:
project?.report_config?.custom_outline || defaultReport.custom_outline,
});
const [strategy, setStrategy] = useState<ProjectStrategyConfig>({
...defaultStrategy,
...project?.strategy_config,
lenses: project?.strategy_config?.lenses?.length
? project.strategy_config.lenses
: defaultStrategy.lenses,
});
const save = useMutation({
mutationFn: () => {
const fields: ProjectFields = {
name: name.trim(),
background: background.trim(),
agent_id: agentId,
report_config:
mode === "solution"
? report
: { ...defaultReport, custom_outline: "" },
strategy_config: strategy,
knowledge_base_ids: [],
assistant_knowledge_base_ids: [],
};
return project
? updateAnalysisProject(project.id, fields)
: createAnalysisProject({ ...fields, mode });
},
onSuccess: async (result) => {
queryClient.setQueryData([...projectKeys(), result.id], result);
await queryClient.invalidateQueries({ queryKey: projectKeys() });
toast.success(project ? "项目配置已保存" : "项目已创建");
navigate(`${BASE}/${result.id}`);
},
});
return (
<form
className="mx-auto w-full max-w-4xl space-y-6 p-5 md:p-7"
onSubmit={(e) => {
e.preventDefault();
if (!save.isPending && name.trim()) save.mutate();
}}
>
<div className="flex items-center gap-3">
<SidebarTrigger />
<Button type="button" variant="ghost" onClick={onCancel}>
<ArrowLeft className="size-4" />
返回
</Button>
<h1 className="text-xl font-semibold">
{project ? "项目设置" : "新建项目"}
</h1>
</div>
<label className="block space-y-2 text-sm">
<span>项目名称</span>
<Input
required
maxLength={160}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:区域市场背景研究"
/>
</label>
<fieldset className="space-y-3">
<legend className="text-sm font-medium">项目问答模式</legend>
{project ? (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Badge variant="secondary">{PROJECT_MODES[mode].name}</Badge>
<span className="text-xs text-muted-foreground">
创建时已确定,不可更改
</span>
</div>
) : (
<>
<div className="grid gap-3 lg:grid-cols-3">
{(Object.keys(PROJECT_MODES) as ProjectMode[]).map((value) => (
<label
key={value}
className={cn(
"flex cursor-pointer gap-3 rounded-xl border p-4",
mode === value && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="project-mode"
checked={mode === value}
onChange={() => setMode(value)}
className="mt-1 accent-primary"
/>
<span>
<span className="text-sm font-medium">
{PROJECT_MODES[value].name}
</span>
<span className="mt-2 block text-xs leading-5 text-muted-foreground">
{PROJECT_MODES[value].description}
</span>
</span>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">
模式创建后不可更改,项目内所有对话使用同一模式。
</p>
</>
)}
</fieldset>
<section className="space-y-2">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="项目默认智能体"
/>
<p className="text-xs text-muted-foreground">
沿用智能体的能力、技能和知识配置。新建对话时可以选择其他智能体。
</p>
</section>
<label className="block space-y-2 text-sm">
<span>项目说明</span>
<Textarea
value={background}
maxLength={12000}
onChange={(e) => setBackground(e.target.value)}
rows={3}
placeholder="填写项目目标、业务背景与约束…"
/>
</label>
{mode === "solution" && (
<ReportConfiguration value={report} onChange={setReport} />
)}
{mode === "strategy" && (
<StrategyConfiguration value={strategy} onChange={setStrategy} />
)}
{save.error && <ErrorState error={save.error} />}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onCancel}>
取消
</Button>
<Button
type="submit"
disabled={
save.isPending ||
!name.trim() ||
(mode === "solution" && !report.custom_outline.trim())
}
>
{save.isPending && <Loader2 className="size-4 animate-spin" />}
{project ? "保存设置" : "创建项目"}
</Button>
</div>
</form>
);
}
function ProjectList() {
const query = useAnalysisProjects();
return (
<div className="h-full overflow-y-auto p-5 md:p-7">
<div className="mx-auto max-w-6xl">
<header className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<SidebarTrigger />
<div>
<h1 className="text-xl font-semibold">项目空间</h1>
<p className="mt-1 text-sm text-muted-foreground">
按项目归类对话,使用熟悉的问答工作区。
</p>
</div>
</div>
<Button asChild>
<Link to={`${BASE}/new`}>
<Plus className="size-4" />
新建项目
</Link>
</Button>
</header>
{query.isLoading ? (
<Loading />
) : query.error ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<div className="mt-6 grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
{query.data?.projects.map((project) => (
<article
key={project.id}
className="rounded-xl border bg-card p-5"
>
<Badge variant="secondary">
{PROJECT_MODES[project.mode].name}
</Badge>
<h2 className="mt-4 text-lg font-medium">{project.name}</h2>
<p className="mt-2 line-clamp-2 min-h-10 text-sm text-muted-foreground">
{project.background ||
PROJECT_MODES[project.mode].description}
</p>
<Button asChild variant="outline" className="mt-4 w-full">
<Link to={`${BASE}/${project.id}`}>进入项目</Link>
</Button>
</article>
))}
</div>
)}
{!query.isLoading && !query.error && !query.data?.projects.length && (
<div className="mt-8 rounded-xl border border-dashed p-10 text-center text-muted-foreground">
<FolderOpen className="mx-auto mb-3 size-8" />
创建项目,将相关问题和分析放在一起。
</div>
)}
</div>
</div>
);
}
function WorkspaceContent({ project }: { project: AnalysisProject }) {
const location = useLocation(),
navigate = useNavigate(),
queryClient = useQueryClient();
const threadsQuery = useProjectThreads(project.id),
match = useMatch(`${BASE}/:projectId/chats/:thread_id`);
const threadId = match?.params.thread_id,
access = useProjectThreadAccess(project.id, threadId);
const [agentId, setAgentId] = useState<string | null>(project.agent_id);
const [lensDraft, setLensDraft] = useState("");
const [addingLens, setAddingLens] = useState(false);
const strategyLenses = project.strategy_config?.lenses?.length
? project.strategy_config.lenses
: [...DEFAULT_STRATEGY_LENSES];
const saveLenses = useMutation({
mutationFn: (lenses: string[]) =>
updateAnalysisProject(project.id, {
name: project.name,
background: project.background,
agent_id: project.agent_id,
report_config: project.report_config,
strategy_config: { lenses },
knowledge_base_ids: [],
assistant_knowledge_base_ids: [],
}),
onSuccess: (updated) => {
queryClient.setQueryData([...projectKeys(), project.id], updated);
void queryClient.invalidateQueries({ queryKey: projectKeys() });
},
onError: (error) => toast.error(error.message),
});
const addLens = () => {
const raw = lensDraft.trim();
const lens = raw ? `${raw.replace(/视角$/, "")}视角` : "";
if (!lens || strategyLenses.includes(lens) || strategyLenses.length >= 10)
return;
saveLenses.mutate([...strategyLenses, lens]);
setLensDraft("");
setAddingLens(false);
};
const create = useMutation({
mutationFn: () => createProjectThread(project.id, agentId),
onSuccess: async (thread) => {
await queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
await queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
navigate(`${BASE}/${project.id}/chats/${thread.thread_id}`);
},
onError: (e) => toast.error(e.message),
});
const threads = threadsQuery.data?.threads ?? [];
useEffect(() => {
if (threadsQuery.data) {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}
}, [queryClient, threadsQuery.data]);
if (location.pathname.endsWith("/settings"))
return (
<div className="h-full overflow-y-auto">
<ProjectForm
key={project.updated_at}
project={project}
onCancel={() => navigate(`${BASE}/${project.id}`)}
/>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col">
<header className="shrink-0 border-b bg-background px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<SidebarTrigger />
<Link to={BASE} className="text-sm text-muted-foreground">
项目空间
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="min-w-0 flex-1 truncate font-semibold">
{project.name}
</h1>
<Badge variant="secondary">{PROJECT_MODES[project.mode].name}</Badge>
<LockKeyhole
className="size-3 text-muted-foreground"
aria-label="项目模式已锁定"
/>
<Button size="sm" variant="ghost" asChild>
<Link to={`${BASE}/${project.id}/settings`}>
<Settings className="size-4" />
{project.mode === "solution"
? "报告结构与设置"
: project.mode === "strategy"
? "分析视角与设置"
: "项目设置"}
</Link>
</Button>
</div>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div className="w-48">
<AgentSelect
value={agentId}
onChange={setAgentId}
label="新对话使用的智能体"
/>
</div>
<Button
size="sm"
disabled={create.isPending}
onClick={() => create.mutate()}
>
<Plus className="size-4" />
新建对话
</Button>
<label className="ml-auto flex max-w-full items-center gap-2 text-xs text-muted-foreground">
<span>项目对话</span>
<select
aria-label="项目对话"
className="h-9 max-w-56 rounded-md border bg-background px-2 text-sm"
value={threadId ?? ""}
onChange={(e) =>
navigate(
e.target.value
? `${BASE}/${project.id}/chats/${e.target.value}`
: `${BASE}/${project.id}`,
)
}
>
<option value="">选择历史对话</option>
{threads.map((t) => (
<option key={t.thread_id} value={t.thread_id}>
{t.title || "新对话"}
</option>
))}
</select>
</label>
</div>
{project.mode === "strategy" && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-xs font-medium text-muted-foreground">
分析视角
</span>
{strategyLenses.map((lens) => (
<Badge key={lens} variant="outline" className="gap-1 bg-background">
{lens}
<button
type="button"
aria-label={`删除视角 ${lens}`}
disabled={strategyLenses.length === 1 || saveLenses.isPending}
onClick={() =>
saveLenses.mutate(
strategyLenses.filter((item) => item !== lens),
)
}
>
<X className="size-3" />
</button>
</Badge>
))}
{addingLens ? (
<div className="flex items-center gap-1">
<Input
autoFocus
aria-label="添加分析视角"
className="h-7 w-40 text-xs"
value={lensDraft}
maxLength={38}
placeholder="如:就业安全"
onChange={(event) => setLensDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addLens();
if (event.key === "Escape") setAddingLens(false);
}}
/>
<Button type="button" size="sm" className="h-7" onClick={addLens}>
确定
</Button>
</div>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
disabled={strategyLenses.length >= 10}
onClick={() => setAddingLens(true)}
>
<Plus className="size-3" />
添加视角
</Button>
)}
</div>
)}
{threadsQuery.error && (
<ErrorState
error={threadsQuery.error}
retry={() => void threadsQuery.refetch()}
/>
)}
</header>
{threadId ? (
access.isLoading ? (
<Loading />
) : access.error ? (
<ErrorState error={access.error} />
) : (
<ProjectConversation
key={threadId}
project={project}
threadId={threadId}
agentId={
typeof access.data?.metadata?.agent_id === "string"
? access.data.metadata.agent_id
: null
}
/>
)
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-3xl py-8">
<Bot className="mb-4 size-8 text-primary" />
<h2 className="text-xl font-medium">在项目中继续问答</h2>
<p className="mt-3 text-sm text-muted-foreground">
{project.background || PROJECT_MODES[project.mode].description}
。选择智能体后新建对话,已有对话也会正常出现在左侧聊天记录中。
</p>
{project.mode === "strategy" && (
<div className="mt-6 grid gap-3 md:grid-cols-3">
{[
"01 意图理解",
"02 多视角策略分析与辩论",
"03 完整策略方案与执行路线",
].map((s) => (
<div className="rounded-xl border p-5 text-sm" key={s}>
{s}
</div>
))}
</div>
)}
<div className="mt-6 divide-y rounded-xl border">
{threads.map((t) => (
<Link
key={t.thread_id}
to={`${BASE}/${project.id}/chats/${t.thread_id}`}
className="block px-4 py-3 text-sm hover:bg-muted/40"
>
{t.title || "新对话"}
</Link>
))}
</div>
</div>
</div>
)}
</div>
);
}
function ProjectWorkspace() {
const { projectId = "" } = useParams();
const query = useAnalysisProject(projectId);
return query.isLoading ? (
<Loading />
) : query.error || !query.data ? (
<ErrorState error={query.error} retry={() => void query.refetch()} />
) : (
<WorkspaceContent key={query.data.id} project={query.data} />
);
}
function NewProject() {
const navigate = useNavigate();
return (
<div className="h-full overflow-y-auto">
<ProjectForm onCancel={() => navigate(BASE)} />
</div>
);
}
export default function AnalysisProjectsPage() {
const { userId } = useLiveAccount();
return (
<Routes key={userId ?? "anonymous"}>
<Route index element={<ProjectList />} />
<Route path="new" element={<NewProject />} />
<Route path=":projectId/*" element={<ProjectWorkspace />} />
</Routes>
);
}
+69 -5
View File
@@ -73,6 +73,7 @@ import { clearResearchReportStream } from "@/core/threads/use-research-report-st
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useDirectChatMessages, isDirectChatProfile, isTurboModeVisible } from "@/core/threads/use-direct-chat";
import { useMultiRoundRagMessages, mergeMessagesByTime, toLangGraphMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
@@ -364,9 +365,12 @@ export default function ChatPage() {
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const directChat = useDirectChatMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const directChatSelected =
isDirectChatProfile(settings.context) && isTurboModeVisible();
// 写作模式深度研究进度:注入 displayThread 尾部(进度步骤条 + report.md
// 流式 stub),成功且真实产物落地后自动撤出。
const { displayThread: researchDisplayThread } = useResearchWritingFeed(threadId, thread);
@@ -376,13 +380,15 @@ export default function ChatPage() {
if (
singleRoundRag.messages.length === 0 &&
ragMessages.length === 0 &&
directChat.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
!multiRoundRag.isLoading &&
!directChat.isLoading
) {
return base;
}
const messages = mergeMessagesByTime(
[...base.messages, ...singleRoundRag.messages],
[...base.messages, ...singleRoundRag.messages, ...directChat.messages],
ragMessages,
);
return {
@@ -390,9 +396,14 @@ export default function ChatPage() {
values: { ...base.values, messages },
messages,
isLoading:
base.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
base.isLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading ||
directChat.isLoading,
} as typeof base;
}, [
directChat.isLoading,
directChat.messages,
multiRoundRag.isLoading,
multiRoundRag.messages,
researchDisplayThread,
@@ -408,6 +419,52 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (directChatSelected) {
if (message.files && message.files.length > 0) {
toast.error("极速问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
if (!targetThreadId) {
toast.error("无法创建会话。");
return;
}
await directChat.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "极速问答失败。",
);
}
})();
return;
}
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
@@ -574,6 +631,8 @@ export default function ChatPage() {
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
directChat,
directChatSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -669,6 +728,10 @@ export default function ChatPage() {
saveLastAgentId(agentId);
}, []);
const handleStop = useCallback(async () => {
if (directChat.isLoading) {
directChat.stop();
return;
}
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
@@ -678,7 +741,7 @@ export default function ChatPage() {
return;
}
await thread.stop();
}, [multiRoundRag, singleRoundRag, thread]);
}, [directChat, multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -928,7 +991,8 @@ export default function ChatPage() {
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading ||
multiRoundRag.isLoading
multiRoundRag.isLoading ||
directChat.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
+18
View File
@@ -2,6 +2,7 @@
import {
BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon,
GraduationCapIcon,
ShrinkIcon,
@@ -399,6 +400,13 @@ export default function IframeChatPage() {
});
}, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => {
if (ragModeLocked) {
return;
@@ -523,6 +531,9 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -544,6 +555,13 @@ export default function IframeChatPage() {
onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700",
};
case "memory":
return {
...button,
icon: BrainCircuitIcon,
onClick: toggleMemoryInjection,
activeClassName: "text-blue-600 hover:text-blue-700",
};
case "references":
return {
...button,
@@ -62,6 +62,7 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -422,6 +423,7 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route
path="business-chain-mapping"
element={
+11 -12
View File
@@ -35,7 +35,6 @@ import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage";
@@ -56,6 +55,7 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
@@ -130,6 +130,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
return (
pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/")
@@ -190,9 +191,7 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname;
const isGeneralQa =
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/workspace/projects");
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
const showCompactTopActions =
headerHidden &&
!compactActionsHidden &&
@@ -549,14 +548,6 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace">
<Routes>
<Route index element={<Navigate to="chats/new" replace />} />
<Route
path="projects/*"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
<AnalysisProjectsPage />
</WorkspaceLayout>
}
/>
<Route
path="chats"
element={
@@ -693,6 +684,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route
path="ai-writing/*"
element={
@@ -1,242 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { MessageSquare, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAgent } from "@/core/agents/hooks";
import { StrategyBoard } from "./StrategyBoard";
import {
PromptInputProvider,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import {
ArtifactsProvider,
ArtifactTrigger,
} from "@/components/workspace/artifacts";
import { ChatBox } from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { useThreadSettings } from "@/core/settings";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed";
import {
PROJECT_MODES,
projectKeys,
type AnalysisProject,
} from "@/core/analysis-projects/api";
function Conversation({
project,
threadId,
agentId,
}: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
}) {
const queryClient = useQueryClient();
const [settings, setSettings] = useThreadSettings(threadId);
const { agent } = useAgent(agentId);
const [showMessages, setShowMessages] = useState(false);
const context = useMemo(
() => ({
model_name: settings.context.model_name,
mode: settings.context.mode,
reasoning_effort: settings.context.reasoning_effort,
writing_mode: project.mode === "solution",
agent_id: agentId ?? undefined,
}),
[
settings.context.model_name,
settings.context.mode,
settings.context.reasoning_effort,
project,
agentId,
],
);
const [thread, sendMessage, isUploading] = useThreadStream({
threadId,
context,
assistantId: agentId || "lead_agent",
runOptions: { onDisconnect: "continue", multitaskStrategy: "reject" },
onFinish: () => {
void queryClient.invalidateQueries({
queryKey: [...projectKeys(), project.id, "threads"],
});
},
});
const { displayThread } = useResearchWritingFeed(threadId, thread);
const submit = useCallback(
(message: PromptInputMessage) => {
if (!thread.isLoading && !isUploading)
void sendMessage(threadId, message);
},
[thread.isLoading, isUploading, sendMessage, threadId],
);
const clarify = useCallback(
async (answer: string) => {
await stopActiveThreadBeforeClarificationReply(threadId, thread);
await sendMessage(threadId, { text: answer, files: [] });
},
[threadId, thread, sendMessage],
);
const hasMessages = displayThread.messages.length > 0;
const strategyActions = [
["改变约束", "请列出当前策略最关键的可调整约束,并询问我希望修改哪一项。"],
["增加视角", "请建议一个当前尚未覆盖的分析视角;说明价值,并在加入该视角后重新推演。"],
["删除视角", "请指出当前贡献最低或重复的分析视角;说明影响,并在移除后重新推演。"],
["重新推演", "请基于当前对话中的全部条件,按完整 Lens 分析、策略辩论和综合流程重新推演。"],
["挑战当前策略", "请从最有力的反方立场挑战当前推荐策略,识别失败条件,并给出必要的调整。"],
] as const;
return (
<ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled: true,
}}
>
<div className="min-h-0 flex-1 overflow-hidden">
<ChatBox threadId={threadId} writingMode={project.mode === "solution"}>
<div className="relative flex h-full min-h-0 min-w-0 flex-col">
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
<span className="truncate text-sm text-muted-foreground">
{displayThread.values.title || "项目对话"}
<span className="ml-2 text-xs">
· {agent?.name || (agentId ? "所选智能体" : "通用助手")}
</span>
</span>
<div className="flex items-center gap-2">
{project.mode === "strategy" && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowMessages((value) => !value)}
>
{showMessages ? "三层策略视图" : "对话与引用"}
</Button>
)}
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</div>
<div className="min-h-0 flex-1">
{project.mode === "strategy" && !showMessages ? (
<StrategyBoard
messages={displayThread.messages}
loading={thread.isLoading}
lenses={project.strategy_config?.lenses ?? []}
onClarificationSubmit={clarify}
/>
) : hasMessages ? (
<MessageList
threadId={threadId}
thread={displayThread}
className="h-full"
paddingBottom={200}
onClarificationSubmit={clarify}
onEditResubmit={(text) => submit({ text, files: [] })}
/>
) : (
<div className="flex h-full flex-col items-center justify-center px-6 pb-44 text-center">
<MessageSquare className="mb-4 size-8 text-primary" />
<h2 className="text-lg font-medium">
{PROJECT_MODES[project.mode].name}
</h2>
<p className="mt-3 max-w-md text-sm text-muted-foreground">
{PROJECT_MODES[project.mode].description}
。在下方输入问题或要求开始。
</p>
{project.mode === "solution" && (
<p className="mt-2 text-xs text-muted-foreground">
按项目所选报告结构生成,正文完成后自动打开方案画布。
</p>
)}
</div>
)}
</div>
<div className="absolute right-0 bottom-0 left-0 z-20 bg-gradient-to-t from-background via-background to-transparent px-4 pt-4 pb-3">
<div className="mx-auto max-w-3xl">
{project.mode === "strategy" && (
<div className="mb-2 flex gap-1.5 overflow-x-auto pb-1" aria-label="策略快捷操作">
{strategyActions.map(([label, prompt]) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 rounded-full bg-background/90 px-2.5 text-xs"
disabled={thread.isLoading || isUploading}
onClick={() => submit({ text: prompt, files: [] })}
>
<Sparkles className="mr-1 size-3" />
{label}
</Button>
))}
</div>
)}
{!!thread.error && (
<p role="alert" className="mb-2 text-sm text-destructive">
{thread.error instanceof Error
? thread.error.message
: "本轮问答失败,请重试"}
</p>
)}
<InputBox
threadId={threadId}
context={context}
hideContextTools
hideAdminRecommendedQuestions
isNewThread={false}
disabled={isUploading}
status={thread.isLoading ? "streaming" : "ready"}
onSubmit={submit}
onStop={() => void thread.stop()}
onContextChange={(next) =>
setSettings("context", {
...settings.context,
model_name:
typeof next.model_name === "string"
? next.model_name
: undefined,
mode: next.mode,
reasoning_effort: next.reasoning_effort,
})
}
/>
<p className="mt-2 text-center text-xs text-muted-foreground">
项目内持续问答 ·{" "}
{agent?.name || (agentId ? "所选智能体" : "通用助手")}
</p>
</div>
</div>
</div>
</ChatBox>
</div>
</ThreadContext.Provider>
);
}
export function ProjectConversation(props: {
project: AnalysisProject;
threadId: string;
agentId: string | null;
}) {
// Remount all conversation-scoped providers together when switching threads.
return (
<SubtasksProvider>
<DocumentRewritePresentationProvider>
<ArtifactsProvider>
<PromptInputProvider>
<Conversation {...props} />
</PromptInputProvider>
</ArtifactsProvider>
</DocumentRewritePresentationProvider>
</SubtasksProvider>
);
}
@@ -1,215 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useEffect, useState } from "react";
import {
ArrowRight,
BrainCircuit,
Compass,
GitBranch,
Loader2,
Medal,
MessagesSquare,
} from "lucide-react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { reasoningPlugins } from "@/core/streamdown";
import {
splitStrategySections,
splitStrategySubsections,
pendingStrategyClarification,
strategyRounds,
STRATEGY_COLUMNS,
} from "@/core/analysis-projects/strategy";
const descriptions = [
"确认 AI 理解的问题、目标、约束、变量与启用视角",
"各 Lens 独立判断并形成策略,再显式分析观点冲突",
"形成可导出、可执行的策略文档,包含路线图、资源、风险与评估机制",
];
const icons = [Compass, GitBranch, Medal];
export function StrategyBoard({
messages,
loading,
lenses,
onClarificationSubmit,
}: {
messages: Message[];
loading: boolean;
lenses: string[];
onClarificationSubmit: (answer: string) => void | Promise<void>;
}) {
const rounds = strategyRounds(messages);
const clarification = pendingStrategyClarification(messages);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => setCustomAnswer(""), [clarification?.question]);
const answerClarification = async (answer: string) => {
if (!answer.trim() || submitting) return;
setSubmitting(true);
try {
await onClarificationSubmit(answer.trim());
} finally {
setSubmitting(false);
}
};
const displayed = rounds.length
? rounds
: [{ id: "empty", question: "从一个问题开始策略分析", answer: "" }];
return (
<div
className="h-full overflow-y-auto px-4 pt-4 pb-56"
data-testid="strategy-board"
>
{displayed.map((round, index) => {
const parts = splitStrategySections(round.answer);
const active = loading && index === displayed.length - 1;
return (
<article key={round.id} className="mb-6">
<div className="mb-3 flex items-center gap-2">
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground">
{index + 1}
</span>
<h2 className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm font-medium">
{round.question}
</h2>
{active && (
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
)}
</div>
<div className="grid items-start gap-3 lg:grid-cols-[minmax(220px,0.75fr)_minmax(340px,1.35fr)_minmax(320px,1.2fr)]">
{STRATEGY_COLUMNS.map((title, column) => {
const Icon = icons[column]!;
const subsections = splitStrategySubsections(parts[column]!);
return (
<section
key={title}
aria-label={title}
className="min-w-0 overflow-hidden rounded-xl border bg-card"
>
<header className="border-b bg-muted/30 px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-primary" />
{title}
</h3>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{descriptions[column]}
</p>
</header>
<div className="min-h-44 break-words p-4 text-sm">
{column === 0 && clarification && (
<div className="mb-4 rounded-lg border border-amber-300/70 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-xs font-medium text-amber-800 dark:text-amber-300">
待确认条件 · 回答后继续多视角推演
</p>
{clarification.context && (
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{clarification.context}
</p>
)}
<p className="mt-2 text-sm font-medium">{clarification.question}</p>
<div className="mt-3 grid gap-2">
{clarification.options.map((option) => (
<Button
key={option.id}
type="button"
variant="outline"
size="sm"
className="h-auto justify-start whitespace-normal bg-background py-2 text-left"
disabled={submitting || loading}
onClick={() => void answerClarification(option.label)}
>
{option.label}
</Button>
))}
</div>
{clarification.allow_custom && (
<div className="mt-2 flex gap-2">
<Input
aria-label="补充澄清条件"
className="h-8 bg-background text-xs"
value={customAnswer}
onChange={(event) => setCustomAnswer(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void answerClarification(customAnswer);
}}
placeholder="输入其他情况"
/>
<Button
type="button"
size="sm"
className="h-8"
disabled={!customAnswer.trim() || submitting || loading}
onClick={() => void answerClarification(customAnswer)}
>
继续
</Button>
</div>
)}
</div>
)}
{column === 1 && (
<div className="mb-4 flex flex-wrap items-center gap-1.5 rounded-lg bg-muted/40 p-2 text-[11px] text-muted-foreground">
<BrainCircuit className="size-3.5 text-primary" />
<span>当前问题</span>
<ArrowRight className="size-3" />
<span>Lens Manager</span>
<ArrowRight className="size-3" />
{lenses.map((lens) => (
<span key={lens} className="rounded-full border bg-background px-2 py-0.5">
{lens}
</span>
))}
</div>
)}
{parts[column] ? (
column === 1 && subsections.length ? (
<div className="grid gap-3 xl:grid-cols-2">
{subsections.map((section) => {
const debate = /策略辩论/.test(section.title);
return (
<section
key={section.title}
className={debate ? "rounded-lg border border-primary/20 bg-primary/5 p-3 xl:col-span-2" : "rounded-lg border bg-background p-3"}
>
<h4 className="mb-2 flex items-center gap-2 font-medium">
{debate ? <MessagesSquare className="size-4 text-primary" /> : <Compass className="size-4 text-primary" />}
{section.title.replace(/^Lens[::]\s*/, "")}
</h4>
<MarkdownContent
content={section.content}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-xs [&_table]:text-xs"
/>
</section>
);
})}
</div>
) : (
<MarkdownContent
content={parts[column]!}
isLoading={active}
rehypePlugins={reasoningPlugins.rehypePlugins}
className="!h-auto !w-full min-w-0 text-sm [&_table]:text-xs"
/>
)
) : (
<p className="py-8 text-center text-xs text-muted-foreground">
{active
? "等待本层分析结果…"
: rounds.length
? "本轮尚未输出该层内容"
: "在下方输入问题后开始"}
</p>
)}
</div>
</section>
);
})}
</div>
</article>
);
})}
</div>
);
}
@@ -175,12 +175,10 @@ export function WorkflowConfigDialog({
appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */
onApply,
inlineCreate = false,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -402,11 +400,6 @@ export function WorkflowConfigDialog({
<Button
className="h-10 gap-1.5 rounded-xl bg-[#F5222D] px-4 text-xs font-semibold text-white shadow-xs transition-all duration-150 hover:bg-[#BC0D1E] hover:shadow active:bg-[#A8071A] dark:bg-[#1677ff] dark:hover:bg-[#4096ff] dark:active:bg-[#0958d9] sm:text-sm"
onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false);
const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`);
@@ -1199,7 +1192,6 @@ export function WorkflowConfigButton({
variant = "outline",
size = "sm",
disabled = false,
inlineCreate = false,
}: {
appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1207,7 +1199,6 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
inlineCreate?: boolean;
}) {
const [open, setOpen] = useState(false);
const location = useLocation();
@@ -1231,7 +1222,6 @@ export function WorkflowConfigButton({
return (
<>
<Button
type="button"
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn(
className,
@@ -1247,7 +1237,6 @@ export function WorkflowConfigButton({
<span className="max-w-40 truncate">{buttonLabel}</span>
</Button>
<WorkflowConfigDialog
inlineCreate={inlineCreate}
open={open}
onOpenChange={setOpen}
appliedContent={appliedContent}
@@ -4,10 +4,11 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts";
test("does not expose a per-chat memory toggle", () => {
test("hides the memory toggle while retaining the other iframe chat toolbar controls", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -27,10 +28,27 @@ test("does not expose a per-chat memory toggle", () => {
);
});
test("can restore the iframe memory toggle when the feature is re-enabled", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
hideMemoryButton: false,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "memory"), true);
});
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false,
ragModeEnabled: true,
ragModeLocked: false,
@@ -45,6 +63,7 @@ test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
@@ -2,6 +2,7 @@ export type IframeModeButtonState = {
key:
| "agent"
| "writing"
| "memory"
| "references"
| "contextCompression"
| "pro";
@@ -13,6 +14,9 @@ export type IframeModeButtonState = {
type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
@@ -23,6 +27,8 @@ type BuildIframeModeButtonsInput = {
export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
@@ -35,6 +41,11 @@ export function buildIframeModeButtons({
label: "写作",
active: writingModeEnabled,
},
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{
key: "references",
label: "参考文献",
@@ -58,6 +69,8 @@ export function buildIframeModeButtons({
},
];
return buttons.filter(
(button) => referenceModeEntryEnabled || button.key !== "references",
(button) =>
(referenceModeEntryEnabled || button.key !== "references") &&
(!hideMemoryButton || button.key !== "memory"),
);
}
@@ -114,12 +114,10 @@ export function PositionBusinessChainFlow({
)}
/>
<div className="min-w-0">
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>
{chain?.title?.trim() || "业务链条"}
</h2>
{!embedded && !chain && (
<h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{!embedded && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{loading ? "正在加载管理员配置…" : "等待管理员配置"}
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
)}
</div>
@@ -34,8 +34,6 @@ export interface PersonaDrawerProps {
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示该行。 */
seatArtifactPositions?: Record<string, number>;
onClose: () => void;
onSave: (id: number) => void;
}
@@ -68,16 +66,11 @@ export function PersonaDrawer({
selectedModel,
availableModels,
modelsLoading,
seatArtifactPositions,
onClose,
onSave,
}: PersonaDrawerProps) {
const cfg = agentConfigs[seatId];
const roleEntry = roles.find((r) => r.id === seatId);
const roleName = roleEntry?.name ?? "专家智能体";
// 该席位在业务链上配置的「产物提交 position」——提交过程产物时传给外部任务系统。
const lookupKey = roleEntry?.agentId?.trim().toLowerCase() || roleName.trim().toLowerCase();
const artifactPosition = lookupKey ? seatArtifactPositions?.[lookupKey] : undefined;
const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
return (
<div className="mx-6 mb-4 p-4.5 bg-gradient-to-r from-[var(--brand-200)]/50 via-[var(--brand-200)]/15 to-white border-2 border-primary rounded-2xl shadow-[0_8px_30px_rgba(245,34,45,0.06)] relative animate-fade-in">
@@ -160,22 +153,6 @@ export function PersonaDrawer({
</select>
</div>
{seatArtifactPositions && (
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-wider block">
产物提交 position
</label>
<div
className="w-full bg-white border border-slate-200 rounded-xl px-2.5 py-1.5 text-xs font-bold font-mono"
title="该席位提交过程产物时传给外部任务系统的 position 值(业务链条里逐席位配置)"
>
{typeof artifactPosition === "number"
? artifactPosition
: <span className="text-slate-400 font-sans">未配置(提交按 0)</span>}
</div>
</div>
)}
<div className="space-y-1">
<div className="flex items-center justify-between text-[9px] font-black text-slate-500">
<span>随机创造性倾向 (Temp)</span>
@@ -115,8 +115,6 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示。 */
seatArtifactPositions?: Record<string, number>;
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
@@ -189,7 +187,6 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs,
selectedAgentId,
roles,
seatArtifactPositions,
seatFilter,
setSeatFilter,
parallelBatch,
@@ -583,7 +580,6 @@ export function Step2Panel(props: Step2PanelProps) {
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
seatArtifactPositions={seatArtifactPositions}
onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona}
/>
@@ -148,11 +148,8 @@ export function useTaskArtifactSubmission(
let record = entriesRef.current[key]?.record;
let reportId = record?.taskDeliveryId || undefined;
const resumedRegistration = Boolean(reportId);
if (!reportId) {
// 外部任务系统(8BF)的实际契约:add 接口以 submitStatus=1 直接完成提交,
// 返回 {code:200, data:id}。登记即提交,不再走「先落 0 再改 1」两段式。
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 1));
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
reportId = receipt.id;
const artifact: RoundtableArtifactReference = {
threadId: target.threadId,
@@ -168,11 +165,9 @@ export function useTaskArtifactSubmission(
taskDeliveryId: reportId,
artifact,
});
} else {
// 仅历史「已登记未提交」的记录(旧流程登记时 submitStatus=0)需要补一次
// update 把外部记录翻成已提交;这条路径失败仍视为提交失败。
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
}
await config.updateProcessReport(reportId, payloadFrom(config, target, content, 1));
const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: true,
taskDeliveryId: reportId,
@@ -361,11 +361,6 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig,
/**
* 席位 → 业务链配置的「产物提交 position」映射(键为小写 agent_id / 席位名)。
* 仅用于「智能体配置」抽屉里的只读展示,帮助核对提交产物时会带的 position 值。
*/
seatArtifactPositions,
/**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
@@ -422,8 +417,6 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string;
hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值),只读展示用。 */
seatArtifactPositions?: Record<string, number>;
step3Mode?: "full" | "report-only";
}) {
const { theme } = useTheme();
@@ -2621,7 +2614,6 @@ export default function RoundtablePlanningPage({
orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole}
seatArtifactPositions={seatArtifactPositions}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
+6 -17
View File
@@ -4,19 +4,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
### 问答记忆运行时总开关
问答记忆由 `system_settings.json -> memory.enabled` 控制,默认 `false`,并且是问答运行时的权威总开关。关闭时 `lead_agent` 不挂载 `MemoryMiddleware`,工具注册不暴露 memory/hindsight 工具,prompt 不读取记忆,因此也不会触发 V1 的延迟提取模型调用。`config.yaml -> memory` 仅保留 provider/version/限额等参数,不能自行启用记忆。管理员通过 `GET/PUT /api/system-settings/memory` 和「设置 → 问答记忆」修改开关;下一次创建的问答立即按新值构建。
### 项目空间(固定问答模式)
- `app/gateway/routers/analysis_projects.py`:用户私有项目 CRUD 和项目下新建/查询对话;更新请求不允许 `mode`,对话归属不通过可编辑 metadata 保存。
- `deerflow.persistence.analysis_projects`:`analysis_projects` / `analysis_project_threads` / additive `analysis_project_configs`,经 models 注册和 Gateway deps 初始化自动建表。配置表保存项目默认智能体、策略 Lens、报告结构快照及每条对话所选智能体,避免修改旧表破坏已有部署。
- `app/gateway/analysis_projects.py::prepare_project_run` 在共享 `start_run` 前置读取绑定、校验所有者及知识库、覆盖运行模式;`authz.py` 同时保护直接读取线程。不要将 `analysis_project` 加入公共 context 白名单。
- `deerflow.agents.analysis_project` 提供三个模式的系统约束和固定方案模板;方案复用 deep_research_report 的 fixed structure,thread_harvester 在方案项目中额外采集服务端检索的引用。普通写作仍使用原有 adaptive 配置。
- 前端 `frontend-web/src/pages/AnalysisProjectsPage.tsx` 和 `pages/analysis-projects/ProjectConversation.tsx` 嵌入现有 WorkspaceSidebar,复用消息流、输入框、研究进度和产物画布;`StrategyBoard.tsx` 只解析对用户可见的「意图理解 / 多视角策略分析与辩论 / 完整策略方案」Markdown,不读取 reasoning/tool payload。完整策略方案以可导出的文档结构呈现。方案设置复用 `WorkflowConfigDialog` 管理报告结构。
- 回归测试:`tests/test_analysis_projects.py`(持久化、权限、模式锁定、运行配置与模板)。
DeerFlow is a LangGraph-based AI super agent system with a full-stack architecture. The backend provides a "super agent" with sandbox execution, persistent memory, subagent delegation, and extensible tool integration - all operating in per-thread isolated environments.
**Architecture**:
@@ -90,7 +77,7 @@ When making code changes, you MUST update the relevant documentation:
### Workflow Studio stream and canvas compatibility
- `PatchedChatOpenAI` accepts provider response fields `reasoning_content` and `reasoning`, normalizing both into `AIMessage.additional_kwargs.reasoning_content`. `app/gateway/workflow_agent_runner.py` is the boundary between LangGraph token chunks and durable workflow events. Provider reasoning in `AIMessage.additional_kwargs.reasoning_content`/`reasoning` is emitted to the UI as balanced inline `<think>` blocks; only visible answer content becomes the node's `text` output for downstream nodes. It must also emit every original `[AIMessage|ToolMessage, metadata]` tuple as `node.message.data.messages` with no whitelist, preview or length crop. Tool results and `additional_kwargs.clarification` are needed by the Studio's DeerFlow-compatible message UI and must survive durable SSE replay.
- `app/gateway/workflow_agent_runner.py` is the boundary between LangGraph token chunks and durable workflow events. Provider reasoning in `AIMessage.additional_kwargs.reasoning_content`/`reasoning` is emitted to the UI as balanced inline `<think>` blocks; only visible answer content becomes the node's `text` output for downstream nodes. It must also emit every original `[AIMessage|ToolMessage, metadata]` tuple as `node.message.data.messages` with no whitelist, preview or length crop. Tool results and `additional_kwargs.clarification` are needed by the Studio's DeerFlow-compatible message UI and must survive durable SSE replay.
- An agent `ask_clarification` ToolMessage pauses the workflow at that agent node (`awaiting_input`), not as a terminal run. The pause records the source `nodeRunId`/`toolCallId`; resuming turns the submitted reply into the next user message on the same `wf-{run}-{node}` LangGraph thread. Keep the raw ToolMessage authoritative; do not invent a second card payload.
- `app/gateway/workflow_proposal_planner.py` converts the workflow controller's selected catalog roles into per-agent `mission`/`deliverable`/`scope`/`handoff` contracts. Only a selected visible `agentId` may contribute a contract; every omitted or malformed field falls back to a deterministic role-aware contract. Preserve this prompt-level worker boundary when adding strategies: researchers must not silently become final writers, and sequential roles must receive their upstream handoff binding plus an explicit next handoff.
- `app/gateway/routers/workflows_coze_compat.py` must return both `creator.self: true` and editable VCS metadata for an owned standalone workflow. Coze Playground derives its preview/read-only mode from those fields, so omitting `creator.self` disables node dragging even when the standalone page passes `readonly={false}`.
@@ -1041,9 +1028,11 @@ scheduler runs once per deployment. Per run:
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
- Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
- Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
### IM Channels System (`app/channels/`)
+1 -12
View File
@@ -51,14 +51,6 @@ Health probes are always unauthenticated, even when API authentication is enable
## Core Components
### 项目空间
前端入口为 `/page/workspace/projects`。项目作为现有问答的分类容器,保留工作区左侧聊天记录。创建项目时选择问答、策略智能或方案生成,创建后不可修改模式;可设置默认智能体,并在新建每条项目对话时另选智能体。方案生成复用「工作流配置 · 报告结构」的结构库和现有写作研究管线;策略智能支持可配置 Lens、多视角独立分析、策略辩论与综合决策。
Gateway `/api/analysis-projects` 提供列表、创建、查询、更新及 `/{id}/threads` 新建和查询对话。项目与对话归属存储在 `analysis_projects` / `analysis_project_threads`,智能体、Lens、报告结构等扩展配置存储在 `analysis_project_configs`;这些表随数据库初始化自动创建,内存部署不跨重启保存。项目按登录用户隔离,模式和归属不接受客户端元数据覆盖。策略智能可在无知识库时基于用户条件推演,但必须标出假设与资料缺口。方案生成调用现有 `deep_research_report` 研究管线,使用项目选择的固定或弹性报告结构,复用方案画布和后续修改流程。
验证:`python -m pytest tests/test_analysis_projects.py tests/test_deep_research_report_tool.py tests/test_writing_mode_report_middleware.py -q`。
### Lead Agent
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines:
@@ -72,7 +64,7 @@ The single LangGraph agent (`lead_agent`) is the runtime entry point, created vi
### Workflow Studio runtime
- Candidate workflow graphs remain editable after they are loaded into the Coze-compatible canvas; the compatibility response explicitly marks the current owner as editable so node dragging is not mistaken for a read-only preview.
- OpenAI-compatible model responses accept both `reasoning_content` and `reasoning`; both are normalized into the existing reasoning display path. Workflow-agent SSE forwards provider reasoning as an inline `<think>…</think>` trace for the UI while keeping the executable node output limited to the visible answer text. Every native LangGraph `[AIMessage|ToolMessage, metadata]` frame is additionally persisted and sent as `node.message.data.messages` without projection or length cropping, so the Studio can replay DeerFlow's tool-result, search-result and `ask_clarification` cards exactly after reconnecting.
- Workflow-agent SSE forwards provider `reasoning_content` as an inline `<think>…</think>` trace for the UI while keeping the executable node output limited to the visible answer text. Every native LangGraph `[AIMessage|ToolMessage, metadata]` frame is additionally persisted and sent as `node.message.data.messages` without projection or length cropping, so the Studio can replay DeerFlow's tool-result, search-result and `ask_clarification` cards exactly after reconnecting.
### AgentScope report collaboration (in progress)
@@ -1260,6 +1252,3 @@ checkpointer:
normalized to the openGauss dialect. Do not configure the LangGraph
PostgreSQL checkpointer against GaussDB: its migrations are PostgreSQL-specific.
For a production image, build with `--build-arg UV_EXTRAS=gauss`.
# 问答记忆开关
问答记忆默认关闭。关闭时,普通问答不会加载记忆中间件、不会注入历史记忆,也不会在回答结束后调用模型提取或写入记忆。管理员可在「设置 → 问答记忆」中显式开启;开关保存在运行目录的 `system_settings.json`,新部署或没有该字段时始终按关闭处理。
@@ -1,143 +0,0 @@
"""Project ownership, knowledge validation and server-controlled run policy."""
from __future__ import annotations
import asyncio
from copy import deepcopy
from fastapi import HTTPException, Request
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.deps import get_agent_store, get_checkpointer, get_optional_user_from_request
from deerflow.agents.analysis_project import SOLUTION_TEMPLATE_ID
from deerflow.config.agents_config import load_agent_config, validate_agent_id
from deerflow.runtime.user_context import get_effective_user_id
async def validate_project_agent(request: Request, agent_id: str | None):
if not agent_id:
return None
try:
validate_agent_id(agent_id)
except ValueError as exc:
raise HTTPException(422, "智能体标识无效") from exc
user_id, is_admin = await actor(request)
store = get_agent_store(request)
row = await store.get_visible(agent_id, user_id)
if row is None and is_admin:
row = await store.get_any(agent_id)
if row is None:
raise HTTPException(422, "所选智能体不存在或无访问权限,请重新选择")
return row
def project_store(request: Request):
store = getattr(request.app.state, "analysis_project_store", None)
if store is None:
raise HTTPException(503, "项目存储尚未就绪")
return store
async def actor(request: Request) -> tuple[str, bool]:
user = await get_optional_user_from_request(request)
if user:
return str(user.id), getattr(user, "system_role", None) == "admin"
if is_auth_disabled():
return get_effective_user_id(), True
raise HTTPException(401, "请先登录")
async def owned_project(request: Request, project_id: str) -> dict:
user_id, _ = await actor(request)
project = await project_store(request).get(project_id, user_id)
if project is None:
raise HTTPException(404, "项目不存在或无权访问")
return project
async def validate_knowledge(request: Request, project: dict) -> None:
user_id, is_admin = await actor(request)
for store_name, field in (("llmwiki_store", "knowledge_base_ids"), ("assistant_knowledge_store", "assistant_knowledge_base_ids")):
ids = project.get(field) or []
if not ids:
continue
store = getattr(request.app.state, store_name, None)
if store is None:
raise HTTPException(503, "知识库服务暂不可用")
for base_id in ids:
row = await store.get_authorized(base_id, user_id, write=False, is_admin=is_admin) if field == "knowledge_base_ids" else await store.get_base(base_id)
if row is None:
raise HTTPException(422, "所选知识库不存在或已无访问权限,请重新配置项目背景")
async def prepare_project_run(body, thread_id: str, request: Request):
"""Resolve policy from the immutable binding, never from caller metadata.
Applies to all Gateway run endpoints, including direct SDK calls. Strip
forged policy even on ordinary threads; all other chat behavior is preserved.
"""
body = body.model_copy(deep=True) if hasattr(body, "model_copy") else deepcopy(body)
body.context = dict(body.context or {})
body.context.pop("analysis_project", None)
body.config = dict(body.config or {})
for key in ("context", "configurable"):
if isinstance(body.config.get(key), dict):
body.config[key].pop("analysis_project", None)
store = getattr(request.app.state, "analysis_project_store", None)
if store is None:
return body, None
project = await store.project_for_thread(thread_id)
if project is None:
return body, None
user_id, _ = await actor(request)
if project["user_id"] != user_id:
raise HTTPException(404, "项目不存在或无权访问")
thread_config = await store.config_for(f"thread:{thread_id}")
agent_id = thread_config.get("agent_id")
await validate_project_agent(request, agent_id)
agent_config = await asyncio.to_thread(load_agent_config, agent_id) if agent_id else None
knowledge_ids = list(getattr(agent_config, "llmwiki_knowledge_base_ids", None) or [])
await validate_knowledge(request, {"knowledge_base_ids": knowledge_ids})
for message in (body.input or {}).get("messages", []):
if isinstance(message, dict) and isinstance(message.get("additional_kwargs"), dict):
# Source snapshots must come from this run's server retrieval.
for key in ("llmwiki_reference_sources", "llmwiki_reference_query", "prompt_prefix"):
message["additional_kwargs"].pop(key, None)
artifact_path = None
if project["mode"] == "solution":
checkpoint = await get_checkpointer(request).aget_tuple({"configurable": {"thread_id": thread_id}})
values = checkpoint.checkpoint.get("channel_values", {}) if checkpoint else {}
if "/mnt/user-data/outputs/report.md" in (values.get("artifacts") or []):
artifact_path = "/mnt/user-data/outputs/report.md"
policy = {
"analysis_project": {
"id": project["id"],
"name": project["name"],
"mode": project["mode"],
"background": project["background"],
"template_id": SOLUTION_TEMPLATE_ID,
"report_config": project.get("report_config") or {},
"strategy_config": project.get("strategy_config") or {},
},
"llmwiki_knowledge_base_ids": knowledge_ids,
"assistant_knowledge_base_ids": [],
"writing_mode": project["mode"] == "solution",
# The artifact path is resolved by the existing agent/middleware, not a
# client-supplied path that could skip the first-report pipeline.
"writing_artifact_path": artifact_path,
"agent_id": agent_id,
"agent_name": None,
"is_plan_mode": False,
"subagent_enabled": False,
"is_bootstrap": False,
"is_writing_setup": False,
"is_report_structure_setup": False,
"knowledge_rag_enabled": False,
}
body.assistant_id = agent_id or "lead_agent"
body.context.update(policy)
body.metadata = {**(body.metadata or {}), "agent_id": agent_id, "agent_name": None, "analysis_project_id": project["id"]}
for key in ("context", "configurable"):
if isinstance(body.config.get(key), dict):
body.config[key].update(policy)
return body, project
@@ -42,6 +42,7 @@ from app.gateway.routers import (
compact_menu_overrides,
dashboard_sessions,
deep_research,
direct_chat,
external_llmwiki,
feedback,
fixed_questions,
@@ -1020,15 +1021,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Run Alembic migrations before anything else touches the schema.
await _run_db_migrations()
# Local Wiki mirror/index scans are opt-in and run outside request
# handling. Per-library database leases keep multi-worker startup safe.
app.state.llmwiki_index_scheduler_task = None
local_wiki_index = app.state.config.llmwiki.local_wiki_index
if (
local_wiki_index.enabled
and local_wiki_index.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
):
# The dormant scheduler also watches config.yaml/system-settings
# changes. Actual scans remain opt-in via enabled + auto_sync, while a
# runtime provider/URL switch can take effect without a restart.
from app.gateway.llmwiki_index_scheduler import local_wiki_index_scheduler_loop
app.state.llmwiki_index_scheduler_task = asyncio.create_task(
@@ -1615,6 +1610,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
from app.gateway.routers import wiki_packages
app.include_router(wiki_packages.router)
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router)
@@ -1625,10 +1621,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(browser_context.router)
# Thread cleanup API is mounted at /api/threads/{thread_id}
from app.gateway.routers import analysis_projects
app.include_router(analysis_projects.router)
app.include_router(threads.router)
# Agents API is mounted at /api/agents
app.include_router(agents.router)
app.include_router(strategy_agents.router)
@@ -1830,6 +1824,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# 开放问答接口 /api/open/chat —— 无需登录,指定模型 + 智能体做问答,可关闭思考模式
app.include_router(open_chat.router)
# 极速问答 /api/direct-chat/stream —— 直连大模型流式输出,不走 lead agent / 工具 / 技能
app.include_router(direct_chat.router)
# 开放接口 /api/open/3qfx/ask —— 无需登录,按 taskId 直接发起 3qfx 第一次问答(后台运行,落成真实会话)
app.include_router(open_3qfx.router)
@@ -336,11 +336,6 @@ def require_permission(
thread_store = get_thread_store(request)
user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None)
if project_store is not None:
project = await project_store.project_for_thread(thread_id)
if project and project["user_id"] != user_id:
raise HTTPException(status_code=404, detail="Project thread not accessible")
allowed = await thread_store.check_access(
thread_id,
user_id,
@@ -307,6 +307,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# Candidate plans are durable user-editable drafts, deliberately kept
# separate from both the canvas draft and a formal workflow run.
app.state.workflow_planning_store = make_workflow_planning_store(sf)
from app.report_collaboration.execution.live_hub import PublishingReportCollaborationStore, ReportCollaborationLiveHub
from deerflow.persistence.report_collaboration import make_report_collaboration_store
@@ -350,10 +351,6 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.analysis_projects import AnalysisProjectStore
app.state.analysis_project_store = AnalysisProjectStore(sf)
from deerflow.persistence.report_structures import make_report_structure_store
app.state.report_structure_store = make_report_structure_store(sf)
@@ -1,164 +0,0 @@
"""Project CRUD and project-owned conversations (mode chosen once)."""
from __future__ import annotations
import asyncio
from typing import Annotated, Literal
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator
from app.gateway.analysis_projects import actor, owned_project, project_store, validate_knowledge, validate_project_agent
from app.gateway.deps import get_checkpointer, get_thread_store
from app.gateway.routers.threads import ThreadCreateRequest, create_thread
from deerflow.agents.analysis_project import SOLUTION_OUTLINE, SOLUTION_TEMPLATE_ID
router = APIRouter(prefix="/api/analysis-projects", tags=["analysis-projects"])
KnowledgeId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)]
DEFAULT_STRATEGY_LENSES = ["增长视角", "风险视角", "竞争视角", "资源视角", "长期价值视角"]
class ProjectReportConfig(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
custom_outline: str = Field(default="", max_length=60000)
structure_mode: Literal["fixed", "adaptive"] = "fixed"
retrieval_directions: list[Annotated[str, StringConstraints(max_length=200)]] = Field(default_factory=list, max_length=12)
retrieval_skills: list[
Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64, pattern=r"^\S+$")]
] = Field(default_factory=list, max_length=8)
class ProjectStrategyConfig(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
lenses: list[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=40)]] = Field(
default_factory=lambda: list(DEFAULT_STRATEGY_LENSES), min_length=1, max_length=10
)
@field_validator("lenses")
@classmethod
def unique_lenses(cls, value: list[str]) -> list[str]:
return list(dict.fromkeys(value))
class ProjectFields(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=160)
background: str = Field(default="", max_length=12000)
agent_id: KnowledgeId | None = None
report_config: ProjectReportConfig = Field(default_factory=ProjectReportConfig)
strategy_config: ProjectStrategyConfig = Field(default_factory=ProjectStrategyConfig)
knowledge_base_ids: list[KnowledgeId] = Field(default_factory=list, max_length=30)
assistant_knowledge_base_ids: list[KnowledgeId] = Field(default_factory=list, max_length=30)
@field_validator("knowledge_base_ids", "assistant_knowledge_base_ids")
@classmethod
def unique_ids(cls, value: list[str]) -> list[str]:
return list(dict.fromkeys(value))
class ProjectCreate(ProjectFields):
mode: Literal["qa", "strategy", "solution"]
def public_project(project: dict) -> dict:
return {
**{key: value for key, value in project.items() if key != "user_id"},
"solution_template": {
"id": SOLUTION_TEMPLATE_ID,
"name": "当前项目报告结构",
"outline": (project.get("report_config") or {}).get("custom_outline") or SOLUTION_OUTLINE,
}
if project["mode"] == "solution"
else None,
}
@router.get("")
async def list_projects(request: Request):
user_id, _ = await actor(request)
return {"projects": [public_project(p) for p in await project_store(request).list(user_id)]}
@router.post("", status_code=201)
async def new_project(body: ProjectCreate, request: Request):
user_id, _ = await actor(request)
await validate_knowledge(request, body.model_dump())
await validate_project_agent(request, body.agent_id)
return public_project(await project_store(request).create(user_id, **body.model_dump()))
@router.get("/{project_id}")
async def get_project(project_id: str, request: Request):
return public_project(await owned_project(request, project_id))
@router.put("/{project_id}")
async def update_project(project_id: str, body: ProjectFields, request: Request):
project = await owned_project(request, project_id)
await validate_knowledge(request, {**project, **body.model_dump()})
await validate_project_agent(request, body.agent_id)
updated = await project_store(request).update(project_id, project["user_id"], **body.model_dump())
if updated is None:
raise HTTPException(404, "项目不存在")
return public_project(updated)
@router.get("/{project_id}/threads")
async def project_threads(project_id: str, request: Request):
project = await owned_project(request, project_id)
ids = await project_store(request).thread_ids(project_id, project["user_id"])
threads = []
for thread_id in ids:
row = await get_thread_store(request).get(thread_id, user_id=project["user_id"])
if row:
metadata = row.get("metadata") or {}
if metadata.get("system") or metadata.get("analysis_project_id") != project_id:
metadata = {**metadata, "system": False, "analysis_project_id": project_id}
await get_thread_store(request).update_metadata(thread_id, metadata, user_id=project["user_id"])
row = {**row, "metadata": metadata}
threads.append({**row, "title": row.get("display_name") or (row.get("values") or {}).get("title") or "新对话"})
threads.sort(key=lambda row: str(row.get("updated_at") or ""), reverse=True)
return {"threads": threads}
class ProjectThreadCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
agent_id: KnowledgeId | None = None
@router.post("/{project_id}/threads", status_code=201)
async def new_project_thread(project_id: str, request: Request, body: ProjectThreadCreate | None = None):
project = await owned_project(request, project_id)
store = project_store(request)
thread_id = str(uuid4())
agent_id = body.agent_id if body and "agent_id" in body.model_fields_set else project.get("agent_id")
await validate_project_agent(request, agent_id)
await store.bind_thread(project_id, project["user_id"], thread_id, agent_id=agent_id)
try:
# Binding is server-only; this endpoint never accepts an arbitrary
# existing thread id, so conversations cannot be moved between projects.
body = ThreadCreateRequest(thread_id=thread_id, assistant_id=agent_id or "lead_agent", metadata={"thread_type": "analysis_project", "system": False, "analysis_project_id": project_id, "agent_id": agent_id})
result = await create_thread(body=body, request=request)
except BaseException:
async def cleanup():
await get_thread_store(request).delete(thread_id)
await get_checkpointer(request).adelete_thread(thread_id)
await store.unbind_failed_creation(thread_id)
await asyncio.shield(cleanup())
raise
return result
@router.get("/{project_id}/threads/{thread_id}")
async def get_project_thread(project_id: str, thread_id: str, request: Request):
project = await owned_project(request, project_id)
bound = await project_store(request).project_for_thread(thread_id)
if not bound or bound["id"] != project["id"]:
raise HTTPException(404, "该对话不属于当前项目")
row = await get_thread_store(request).get(thread_id, user_id=project["user_id"])
if not row:
raise HTTPException(404, "对话不存在")
return row
@@ -821,16 +821,8 @@ async def _ensure_local_wiki_vectors_for_export(
) -> None:
mapping_id = str(mapping.get("id") or mapping.get("knowledge_base_id") or "")
service = getattr(request.app.state, "llmwiki_sync_service", None)
if not mapping_id:
if service is None or not mapping_id:
raise RuntimeError("请先配置 Wiki 编码模型并启用本地向量索引,再生成向量数据包")
if service is None:
# Exporting an existing snapshot does not require an encoder. The
# snapshot already records its model fingerprint and dimensions, so a
# deployment that encoded online (or later disabled the encoder) can
# still package those vectors without silently re-encoding them.
if await _has_local_wiki_vectors_for_export(request, mapping_id):
return
raise RuntimeError("当前知识库没有可导出的本地 Wiki 向量;请先完成向量同步")
# Refresh remote metadata/content hashes even when the previous generation
# was complete. Unchanged articles reuse their existing vectors.
sync_mapping = dict(mapping)
@@ -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",
},
)
@@ -318,35 +318,6 @@ class ModelConcurrencyUpdateRequest(BaseModel):
user_model_limits: dict[str, int] | None = None
class MemoryRuntimeResponse(BaseModel):
enabled: bool = Field(
default=False,
description="Whether Q&A memory is enabled deployment-wide",
)
class MemoryRuntimeUpdateRequest(BaseModel):
enabled: bool
@router.get("/memory", response_model=MemoryRuntimeResponse)
async def get_memory_runtime_setting() -> MemoryRuntimeResponse:
return MemoryRuntimeResponse(enabled=load_system_settings().memory.enabled)
@router.put("/memory", response_model=MemoryRuntimeResponse)
async def update_memory_runtime_setting(
body: MemoryRuntimeUpdateRequest,
request: Request,
) -> MemoryRuntimeResponse:
await _require_admin(request)
current = load_system_settings()
updated = current.memory.model_copy(update={"enabled": body.enabled})
saved = save_system_settings(current.model_copy(update={"memory": updated}))
logger.info("Q&A memory runtime switch changed: enabled=%s", saved.memory.enabled)
return MemoryRuntimeResponse(enabled=saved.memory.enabled)
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
return SkillCompressionResponse(
enabled=settings.enabled,
@@ -928,9 +928,6 @@ async def start_run(
router) and ``[leader-timing] / [special-timing]`` (圆桌 leader/special).
"""
t_entry = time.perf_counter()
from app.gateway.analysis_projects import prepare_project_run
body, analysis_project = await prepare_project_run(body, thread_id, request)
bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request)
run_ctx = get_run_context(request)
@@ -951,7 +948,7 @@ async def start_run(
effective_user_id = get_effective_user_id()
effective_run_scope = _resolve_run_scope(body, request, run_scope)
graph_input = normalize_input(body.input)
fixed_answer = None if analysis_project else await _find_fixed_answer(
fixed_answer = await _find_fixed_answer(
body=body,
request=request,
graph_input=graph_input,
@@ -1173,11 +1170,6 @@ async def start_run(
if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value
merge_run_context_overrides(config, body_context or None)
if analysis_project:
# This key is deliberately absent from the public context whitelist.
# Only the validated project binding can supply the mode contract.
for key in ("configurable", "context"):
config.setdefault(key, {})["analysis_project"] = body.context["analysis_project"]
stream_modes = normalize_stream_modes(body.stream_mode)
+33 -14
View File
@@ -33,16 +33,26 @@ concurrency:
fail_open: true
models:
- name: MiniMax-M2.7
display_name: MiniMax-M2.7
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.7
api_key: sk-cp-mXNcPkl81WAERsVaVOCj5r-F3LQv8kdtMWzx_U4RNAJejJKtK_JB2mHLFqBkBYvVDb8OPZmxJgiuctDHhDImvkHvJqGj_RvvlTaCzFvhwIvuHT8jm46niBY
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
- name: qwen3.6
display_name: qwen3.6
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
when_thinking_disabled:
extra_body:
chat_template_kwargs:
enable_thinking: false
- name: MiniMax-M2.7-fash
display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI
@@ -69,16 +79,24 @@ models:
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_thinking: true
supports_reasoning_effort: false
supports_vision: false
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
when_thinking_disabled:
extra_body:
chat_template_kwargs:
enable_thinking: false
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
@@ -474,10 +492,11 @@ checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
# 记忆子系统参数。真正的问答总开关由管理员「设置 → 问答记忆」控制,
# system_settings.json 没有显式开启时始终关闭;这里的 enabled 不会绕过管理员总开关。
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: false
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
@@ -123,6 +123,27 @@ models:
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
**vLLM (per-model, do not change other entries)**:
`use` is per model. SiliconFlow / DeepSeek entries should stay on `langchain_openai:ChatOpenAI`. For a self-hosted vLLM reasoning model (thinking may arrive as `reasoning` on newer vLLM, or `reasoning_content` on older builds), set only that one entry to `VllmChatModel`:
```yaml
models:
- name: qwen3.6
display_name: qwen3.6
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: $VLLM_API_KEY
base_url: http://127.0.0.1:8001/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
```
The default `langchain_openai:ChatOpenAI` path also maps both thinking field names onto the UI's `reasoning_content`. Use `VllmChatModel` when follow-up turns must echo vLLM's original `reasoning` field.
**Thinking Models**:
Some models support "thinking" mode for complex reasoning:
@@ -1,82 +0,0 @@
"""Server-selected project mode contracts; ordinary chat remains unchanged."""
import json
SOLUTION_OUTLINE = """# 一、背景与依据
# 二、目标与约束
# 三、实施路径与重点任务
# 四、资源配置与时间计划
# 五、风险与应对措施
# 六、评估指标与参考依据"""
SOLUTION_TEMPLATE_ID = "project-implementation-v1"
STRATEGY_INSTRUCTION = """当前项目固定为策略智能模式(多视角战略推演与策略综合)。
依次完成问题重构、Lens 独立分析、观点冲突识别和完整策略方案。最终交付物是一份可直接阅读、导出和执行的 Markdown 策略文档,不是简短结论。输出可审查的判断摘要和依据,不披露内部思维链。
每个 Lens 必须独立形成判断和候选策略,不能先确定结论再倒推。显式说明 Lens 之间的冲突及化解方式。
区分引用事实、推断和假设;置信度和综合评分必须说明依据,证据不足时不得伪造精确数据。
无知识库时仍可基于用户条件分析,但要明确假设与信息缺口。用户改变约束、增删视角、要求重推或挑战策略时,使用当前对话全部条件重新推演。
先完成能确定的目标理解和变量识别。只有会实质改变策略推荐的关键条件缺失时才提出少量澄清;澄清会显示在左侧意图理解区,用户回答后继续完整推演。一般信息缺口应采用明确假设并继续分析。
策略推演直接由你完成,不存在 `strategy_recommendation` 或类似专用工具,禁止虚构或调用此类工具。需要事实证据时可使用实际提供的检索工具;工具返回后必须继续生成下面规定的完整最终回答。
控制内容密度,避免机械重复;每个 Lens 只保留影响决策的判断、策略和评分依据。
最终回答必须依次使用以下 Markdown 标题(逐字保持):
## 意图理解
### 原始问题
### AI问题重构
说明目标、核心任务、优化方向和约束。
### 关键变量
列出影响决策的关键因素。
### 本次分析视角
列出启用的 Lens,并说明每个视角的核心关注。
## 多视角策略分析
每个启用 Lens 使用三级标题 `### Lens:视角名称`,并依次给出:核心关注、判断、推荐策略、主要收益、主要风险、关键假设、置信度及依据。
### 策略辩论
写清不同 Lens 的支持、反驳与约束,提炼核心冲突,并给出冲突化解方式。
## 完整策略方案
### 方案标题
使用明确、可识别的策略方案名称。
### 执行摘要
说明最终推荐、核心思想、综合评分与评分依据,以及预期解决的问题。
### 策略目标与原则
列出总体目标、阶段目标、执行原则、适用条件和边界。
### 分阶段行动路线图
使用 Markdown 表格列出阶段/时间、关键行动、交付物、责任主体或协作对象、完成标准;随后补充关键动作说明。内容必须足以指导执行,不能只有口号。
### 资源与能力配置
说明时间、预算、工具、知识和协作资源,以及需要补齐的能力。
### 风险与应对
列出主要风险、预警信号、预防措施、应急动作和失败/停止条件。
### 评估指标与复盘机制
给出过程指标、结果指标、检查频率、复盘方法和调整规则。
### 形成依据
说明各 Lens 对综合策略的贡献;只有依据充分时才给百分比,否则使用高/中/低并解释。
### 备选策略与切换条件
逐项写出未选择策略的优势、未选原因以及何时应切换到该策略。
不能确定时明确还缺少哪些条件。
维持当前模式,不在此项目内切换到方案生成。"""
def project_system_prompt(context: dict) -> str:
project = context.get("analysis_project")
if not isinstance(project, dict) or project.get("mode") not in {"qa", "strategy", "solution"}:
return ""
mode = project["mode"]
instructions = {
"qa": "当前项目固定为问答模式。基于项目资料解释、总结并持续问答;不强制运行策略优选或方案生成流程。",
"strategy": STRATEGY_INSTRUCTION,
"solution": (
"当前项目固定为方案生成模式。通过对话明确要求,使用项目知识库资料。"
"用户要求交付方案时调用 deep_research_report,经现有研究管线写入方案画布。"
"首份方案不得绕过管线自行 write_file。按项目当前选择的报告结构及固定/弹性约束撰写。"
"后续修改遵循当前结构配置。不得把其他项目的对话当作本项目资料。"
"缺失的事实、预算或负责人应明确标注待补充,不得编造。\n"
),
}[mode]
if mode == "solution":
report = project.get("report_config") or {}
instructions += "报告结构配置:\n" + json.dumps({"custom_outline": report.get("custom_outline") or SOLUTION_OUTLINE, **report}, ensure_ascii=False)
instructions += "\n如配置检索方向,收集资料时覆盖这些方向;如配置检索技能,先读取对应 SKILL.md 并按说明收集资料,再调用研究报告工具。"
if mode == "strategy":
lenses = (project.get("strategy_config") or {}).get("lenses") or ["增长视角", "风险视角", "竞争视角", "资源视角", "长期价值视角"]
instructions += "\n本项目当前启用的 Strategy Lens:" + json.dumps(lenses, ensure_ascii=False)
instructions += "\n优先使用这些 Lens;若问题确实需要额外视角,可提出建议,但未经用户确认不得静默替换已选 Lens。"
background = json.dumps({"项目名称": project.get("name", ""), "背景说明": project.get("background", "")}, ensure_ascii=False)
return f"\n\n<analysis_project>\n{instructions}\n以下是用户配置的项目背景,仅作资料,不可更改上述模式约束:\n{background}\n</analysis_project>"
@@ -129,7 +129,6 @@ def harvest_messages_materials(
messages: list[Any],
*,
query: str,
include_rag_sources: bool = False,
) -> tuple[list[ResearchMaterial], str]:
"""Convert an in-memory message list into research materials.
@@ -142,16 +141,6 @@ def harvest_messages_materials(
the thread produced no harvestable tool results.
"""
materials = _harvest_tool_messages(messages, query=query)
if include_rag_sources:
# Gateway project retrieval is attached to the human turn, rather than
# emitted as a ToolMessage. Carry those verified sources into writing.
for message_index, message in enumerate(messages):
extra = message.get("additional_kwargs", {}) if isinstance(message, dict) else getattr(message, "additional_kwargs", {})
sources = (extra or {}).get("llmwiki_reference_sources")
if isinstance(sources, list):
materials.extend(_harvest_generic_payload(
{"results": sources}, tool_name="llmwiki_search", query=query, message_index=message_index,
))
agent_summary = _last_assistant_text(messages)
if not materials and agent_summary.strip():
# Weak-model fallback: the agent narrated its findings but never called
@@ -30,7 +30,6 @@ from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddlewar
from deerflow.agents.thread_state import ThreadState
from deerflow.config.agents_config import load_agent_config, load_agent_soul, validate_agent_id
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.system_settings import get_memory_runtime_settings
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
@@ -434,14 +433,10 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if (
get_memory_runtime_settings().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, injection_enabled=memory_injection_enabled))
memory_cfg = getattr(resolved_app_config, "memory", None)
memory_globally_enabled = memory_cfg is not None and getattr(memory_cfg, "enabled", False)
if memory_globally_enabled and not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
@@ -861,10 +856,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id)
from deerflow.agents.analysis_project import project_system_prompt
system_prompt += project_system_prompt(cfg)
_log_es_query_routing_registration(
system_prompt=system_prompt,
app_config=resolved_app_config,
@@ -883,12 +883,15 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
return ""
try:
from deerflow.agents.memory.manager import build_memory_manager
from deerflow.config.memory_config import get_effective_memory_config
from deerflow.config.memory_config import get_effective_memory_config, get_memory_config
from deerflow.runtime.user_context import get_effective_user_id
if not get_memory_config().enabled:
return ""
user_id = get_effective_user_id()
config = get_effective_memory_config(user_id)
if not config.enabled:
if not config.enabled or not config.injection.enabled:
return ""
manager = build_memory_manager(
@@ -18,9 +18,8 @@ def _get_v1_cfg():
def _memory_enabled() -> bool:
from deerflow.config.system_settings import get_memory_runtime_settings
return get_memory_runtime_settings().enabled
from deerflow.config.memory_config import get_memory_config
return get_memory_config().enabled
@dataclass
@@ -4,7 +4,7 @@ from __future__ import annotations
from deerflow.agents.memory.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
from deerflow.agents.memory.v1.queue import get_memory_queue
from deerflow.config.system_settings import get_memory_runtime_settings
from deerflow.config.memory_config import get_memory_config
def memory_flush_hook(event: object) -> None:
@@ -16,7 +16,7 @@ def memory_flush_hook(event: object) -> None:
- ``agent_name: str | None``
- ``runtime: RunnableConfig``
"""
if not get_memory_runtime_settings().enabled:
if not get_memory_config().enabled:
return
thread_id = getattr(event, "thread_id", None)
@@ -260,9 +260,8 @@ class MemoryUpdater:
reinforcement_detected: bool,
user_id: str | None = None,
) -> tuple[dict[str, Any], str] | None:
from deerflow.config.memory_config import get_effective_memory_config
config = get_effective_memory_config(user_id or "default")
from deerflow.config.memory_config import get_memory_config
config = get_memory_config()
if not config.enabled or not messages:
return None
@@ -18,7 +18,7 @@ LLM 主动写入走 ``memory`` / ``hindsight_*`` 工具,不经过本中间件。
import asyncio
import logging
from typing import Any, override
from typing import TYPE_CHECKING, Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
@@ -28,9 +28,12 @@ from langgraph.runtime import Runtime
from deerflow.agents.memory.manager import build_memory_manager
from deerflow.agents.memory.message_processing import count_user_turns, filter_messages_for_memory
from deerflow.config.memory_config import get_effective_memory_config
from deerflow.config.memory_config import get_effective_memory_config, get_memory_config
from deerflow.runtime.user_context import get_effective_user_id
if TYPE_CHECKING:
from deerflow.config.memory_config import MemoryConfig
logger = logging.getLogger(__name__)
@@ -87,15 +90,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
state_schema = MemoryMiddlewareState
def __init__(self, agent_name: str | None = None, *, injection_enabled: bool = True):
def __init__(self, agent_name: str | None = None, *, memory_config: "MemoryConfig | None" = None, injection_enabled: bool = True):
"""初始化 MemoryMiddleware。
Args:
agent_name: 提供时记忆按 agent 隔离;None 用默认 agent。
memory_config: 显式记忆配置;省略时用全局配置。
injection_enabled: 运行时开关;``False`` 时跳过 Hindsight 动态召回注入。
"""
super().__init__()
self._agent_name = agent_name
self._memory_config = memory_config
self._injection_enabled = injection_enabled
# ---- 运行时上下文解析 -------------------------------------------------
@@ -145,6 +150,10 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
return None
if not self._injection_enabled:
return None
config = self._memory_config or get_memory_config()
if not config.enabled:
return None
user_id = get_effective_user_id()
effective_config = get_effective_memory_config(user_id)
if not effective_config.enabled or not effective_config.injection.enabled:
@@ -212,13 +221,13 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
Returns:
None (本中间件不改状态)。
"""
config = self._memory_config or get_memory_config()
if not config.enabled:
return None
user_id = get_effective_user_id()
effective_config = get_effective_memory_config(user_id)
if (
not effective_config.enabled
or effective_config.provider != "hindsight"
or not effective_config.hindsight.auto_retain
):
if effective_config.provider != "hindsight" or not effective_config.hindsight.auto_retain:
return None
messages = state.get("messages", [])
@@ -12,14 +12,11 @@
injection: {...} # 系统提示词注入
security: {...} # 内容安全扫描
运行时总开关与配置覆盖
======================
管理员维护的 ``system_settings.json -> memory.enabled`` 是权威总开关,默认
``false``。关闭时下面所有 provider 参数都不生效。
两层配置覆盖
============
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True)
(仅白名单字段,不能覆盖 ``enabled``)
2. config.yaml ``memory:`` 段 (即 ``get_memory_config()``)
3. Pydantic 默认值
@@ -35,8 +32,6 @@ from typing import Any
from pydantic import BaseModel, Field
from deerflow.config.system_settings import get_memory_runtime_settings
logger = logging.getLogger(__name__)
@@ -131,7 +126,7 @@ class V1MemoryConfig(BaseModel):
class MemoryConfig(BaseModel):
"""记忆子系统的全局配置 (config.yaml 的 ``memory:`` 段)。"""
enabled: bool = Field(default=False, description="记忆子系统总开关")
enabled: bool = Field(default=True, description="记忆子系统总开关")
version: str = Field(
default="v2",
description="记忆系统版本:v1(原版 DeerFlow,memory.json + LLM 自动提取) 或 v2(USER.md/MEMORY.md)",
@@ -258,22 +253,13 @@ def get_recall_budget_from_cache(user_id: str) -> str | None:
def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
管理员运行时总开关关闭时直接返回 ``enabled=False``;开启后再按
per-user DB 覆盖 (经 cache) > config.yaml > 默认值 合并参数。
优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
``enabled`` 始终以 config.yaml 为准,用户覆盖不能把它重新打开。
"""
# The admin-managed runtime switch is authoritative and defaults to off.
# ``config.yaml`` supplies provider parameters only; it cannot silently
# turn memory on after a fresh deployment or settings-file reset.
runtime_enabled = get_memory_runtime_settings().enabled
if not runtime_enabled:
return _memory_config.model_copy(update={"enabled": False}, deep=True)
base = _memory_config.model_dump()
base["enabled"] = True
recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget
base["enabled"] = True
try:
return MemoryConfig(**base)
except Exception as exc: # noqa: BLE001 - 覆盖产生非法配置时降级
@@ -505,21 +505,6 @@ class ModelConcurrencySettings(BaseModel):
model_config = ConfigDict(extra="ignore")
class MemoryRuntimeSettings(BaseModel):
"""Deployment-wide switch for Q&A memory.
Memory is deliberately opt-in. When this setting is false the lead agent
does not mount memory middleware or memory tools, does not inject stored
memory into prompts, and does not enqueue background memory extraction.
"""
enabled: bool = Field(
default=False,
description="Whether Q&A memory recall, injection and persistence are enabled.",
)
model_config = ConfigDict(extra="ignore")
class LocalWikiEncoderSettings(BaseModel):
"""Runtime override for the administrator-managed offline Wiki encoder."""
@@ -568,7 +553,6 @@ class SystemSettings(BaseModel):
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
memory: MemoryRuntimeSettings = Field(default_factory=MemoryRuntimeSettings)
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
@@ -630,15 +614,6 @@ def get_model_concurrency_settings() -> ModelConcurrencySettings:
return ModelConcurrencySettings()
def get_memory_runtime_settings() -> MemoryRuntimeSettings:
"""Return the admin-controlled Q&A memory switch, failing closed."""
try:
return load_system_settings().memory
except Exception:
logger.debug("Failed to read memory runtime setting; keeping memory disabled", exc_info=True)
return MemoryRuntimeSettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
path = _settings_path()
@@ -31,6 +31,96 @@ def _vllm_disable_chat_template_kwargs(chat_template_kwargs: dict) -> dict:
return disable_kwargs
# Qwen3.8 chat templates only accept these three effort names.
_QWEN_REASONING_EFFORT = {
"minimal": "low",
"low": "low",
"medium": "medium",
"high": "xhigh",
"xhigh": "xhigh",
}
_OPENAI_THINKING_OFF_EXTRA_BODY = {
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def _is_vllm_provider(model_use: str) -> bool:
lowered = (model_use or "").lower()
return "vllm_provider" in lowered or lowered.endswith(":vllmchatmodel")
def _is_qwen38_model(model_config) -> bool:
blob = f"{getattr(model_config, 'name', '')} {getattr(model_config, 'model', '')}".lower()
return "qwen3.8" in blob
def _should_control_openai_thinking(model_config) -> bool:
"""Whether this OpenAI-compatible model needs explicit thinking on/off flags.
Qwen3.8 (and current vLLM reasoning parsers) default to thinking at ``xhigh``.
If DeerFlow does not send ``enable_thinking: false``, the first visible answer
token waits until that hidden reasoning finishes — third-party chat UIs that
disable thinking or stream the reasoning field therefore feel much faster.
"""
if getattr(model_config, "supports_thinking", False):
return True
if _is_vllm_provider(getattr(model_config, "use", "")):
return True
return _is_qwen38_model(model_config)
def _map_qwen_reasoning_effort(effort: object) -> str | None:
if effort is None:
return None
return _QWEN_REASONING_EFFORT.get(str(effort).strip().lower())
def _apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config: dict,
*,
thinking_enabled: bool,
reasoning_effort: object,
) -> None:
"""Inject vLLM/Qwen thinking flags when the UI toggle is on or off."""
if "extra_body" not in getattr(model_class, "model_fields", {}):
return
qwen_vllm = _is_vllm_provider(getattr(model_config, "use", "")) or _is_qwen38_model(model_config)
if thinking_enabled:
if not qwen_vllm:
return
enable_body: dict = {
"enable_thinking": True,
"chat_template_kwargs": {"enable_thinking": True},
}
mapped_effort = _map_qwen_reasoning_effort(reasoning_effort)
if mapped_effort:
enable_body["chat_template_kwargs"]["reasoning_effort"] = mapped_effort
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
enable_body,
)
return
if not _should_control_openai_thinking(model_config):
return
disable_body = (
{
"enable_thinking": False,
"chat_template_kwargs": {"enable_thinking": False},
}
if qwen_vllm
else _OPENAI_THINKING_OFF_EXTRA_BODY
)
model_settings_from_config["extra_body"] = _deep_merge_dicts(
model_settings_from_config.get("extra_body"),
disable_body,
)
def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_config: dict) -> None:
"""Enable stream usage for OpenAI-compatible models unless explicitly configured.
@@ -127,10 +217,19 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
elif has_thinking_settings and effective_wte.get("thinking", {}).get("type"):
# Native langchain_anthropic: thinking is a direct constructor parameter
model_settings_from_config["thinking"] = {"type": "disabled"}
requested_reasoning_effort = kwargs.get("reasoning_effort")
if not model_config.supports_reasoning_effort:
kwargs.pop("reasoning_effort", None)
model_settings_from_config.pop("reasoning_effort", None)
_apply_openai_thinking_control(
model_config,
model_class,
model_settings_from_config,
thinking_enabled=thinking_enabled,
reasoning_effort=requested_reasoning_effort,
)
# Hard override: force-disable thinking regardless of the per-model config shape.
# Injected only for OpenAI-compatible classes (those declaring `extra_body`);
# other providers would reject the kwarg, so they are skipped (they ignore it).
@@ -188,21 +287,6 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
model_instance = model_class(**kwargs, **model_settings_from_config)
# Always emit a compact model lifecycle trace. In particular, the first
# streamed callback proves that the upstream endpoint accepted the request
# and started returning data, including reasoning-only chunks whose normal
# content string is empty.
from deerflow.models.timing_callback import ModelTimingCallback
existing_callbacks = model_instance.callbacks or []
model_instance.callbacks = [
*existing_callbacks,
ModelTimingCallback(
model_name=name,
base_url=str(model_config.base_url) if model_config.base_url else None,
),
]
callbacks = build_tracing_callbacks()
if callbacks:
existing_callbacks = model_instance.callbacks or []
@@ -19,9 +19,8 @@ re-inject thinking metadata back into the outgoing payload for any assistant
message that originally carried it:
- ``thought_signature`` for Gemini-compatible tool calls.
- ``reasoning_content`` and the newer ``reasoning`` spelling used by vLLM and
other OpenAI-compatible providers. Both are normalized to
``additional_kwargs.reasoning_content`` for DeerFlow's existing clients.
- ``reasoning_content`` for providers (for example DeepSeek) that require it
to be echoed in multi-turn requests when thinking mode is enabled.
"""
from __future__ import annotations
@@ -35,6 +34,7 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResu
from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, extract_reasoning_text, fill_ui_reasoning_content, restore_reasoning_fields
class OpenAICompatibleUpstreamError(RuntimeError):
@@ -167,7 +167,7 @@ class PatchedChatOpenAI(ChatOpenAI):
default_chunk_class: type,
base_generation_info: dict | None,
) -> ChatGenerationChunk | None:
"""Preserve streamed ``reasoning_content``/``reasoning`` metadata."""
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
# Some compatible gateways return a vendor error event followed by a
# normal-looking usage chunk, all under HTTP 200. Detect the first
# event before LangChain converts it into an empty AIMessageChunk.
@@ -183,13 +183,12 @@ class PatchedChatOpenAI(ChatOpenAI):
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = _append_reasoning_content(
if isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = apply_reasoning_from_payload(
generation_chunk.message,
reasoning_content,
reasoning=delta.get("reasoning") if isinstance(delta, dict) else None,
delta if isinstance(delta, dict) else None,
)
generation_chunk.message = fill_ui_reasoning_content(generation_chunk.message)
return generation_chunk
def _create_chat_result(
@@ -197,7 +196,7 @@ class PatchedChatOpenAI(ChatOpenAI):
response: dict | Any,
generation_info: dict | None = None,
) -> ChatResult:
"""Preserve non-stream ``reasoning_content``/``reasoning`` metadata."""
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
response_dict = response if isinstance(response, dict) else response.model_dump()
_raise_for_application_error(response_dict)
result = super()._create_chat_result(response, generation_info)
@@ -208,13 +207,15 @@ class PatchedChatOpenAI(ChatOpenAI):
message = generation.message
choice = choices[index] if index < len(choices) else {}
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None)
if reasoning_content and isinstance(message, AIMessage):
message = _append_reasoning_content(
if isinstance(message, AIMessage):
updated = apply_reasoning_from_payload(
message,
reasoning_content,
reasoning=choice_message.get("reasoning") if isinstance(choice_message, dict) else None,
choice_message if isinstance(choice_message, dict) else None,
append=False,
)
updated = fill_ui_reasoning_content(updated)
if updated is not message:
message = updated
generation = ChatGeneration(
message=message,
generation_info=generation.generation_info,
@@ -266,45 +267,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject provider reasoning onto an assistant history message.
"""Re-inject thinking fields onto assistant payload message.
Some thinking-enabled OpenAI-compatible providers require each historical
assistant turn to include its original reasoning in subsequent requests.
Preserve the spelling returned by the provider when available; otherwise
retain the established ``reasoning_content`` behavior.
assistant turn to include the original thinking field in subsequent
requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
vLLM use ``reasoning_content``. Echo only the fields this turn originally
carried so other models are not given an extra field.
"""
reasoning = 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
restore_reasoning_fields(payload_msg, orig_msg)
def _extract_reasoning_content(payload: dict | None) -> str | None:
"""Extract either supported reasoning spelling from provider payloads."""
if not isinstance(payload, dict):
return None
reasoning_content = payload.get("reasoning_content") or payload.get("reasoning")
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None
"""Extract reasoning text from provider payload delta/message objects."""
return extract_reasoning_text(payload)
def _append_reasoning_content(
message: AIMessage | AIMessageChunk,
reasoning_content: str,
*,
reasoning: Any = None,
) -> AIMessage | AIMessageChunk:
"""Normalize reasoning for clients while retaining its provider spelling."""
additional_kwargs = dict(message.additional_kwargs)
existing = additional_kwargs.get("reasoning_content")
if isinstance(existing, str) and existing:
if reasoning_content not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
else:
additional_kwargs["reasoning_content"] = reasoning_content
if reasoning is not None:
additional_kwargs["reasoning"] = reasoning
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
@@ -0,0 +1,140 @@
"""Normalize OpenAI-compatible thinking fields across provider versions.
Older vLLM / DeepSeek put thinking in ``reasoning_content``. Newer vLLM
reasoning parsers put it in ``reasoning``. DeerFlow's UI and downstream code
canonicalize on ``AIMessage.additional_kwargs.reasoning_content``.
Only fields that actually appeared on a response are echoed back on later
turns, so models that still use ``reasoning_content`` (or send neither) are
unchanged.
"""
from __future__ import annotations
import json
from typing import Any
from langchain_core.messages import AIMessage, AIMessageChunk
def reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from provider payloads."""
if isinstance(reasoning, str):
return reasoning
if isinstance(reasoning, list):
parts = [reasoning_to_text(item) for item in reasoning]
return "".join(part for part in parts if part)
if isinstance(reasoning, dict):
for key in ("text", "content", "reasoning"):
value = reasoning.get(key)
if isinstance(value, str):
return value
if value is not None:
text = reasoning_to_text(value)
if text:
return text
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
if reasoning is None:
return ""
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
def extract_reasoning_text(payload: dict | None) -> str | None:
"""Read thinking text from ``reasoning_content`` or ``reasoning``.
Prefer ``reasoning_content`` when both are present so older DeepSeek /
vLLM payloads keep their original string. Newer vLLM that only sends
``reasoning`` (string or structured) is still captured.
"""
if not isinstance(payload, dict):
return None
for key in ("reasoning_content", "reasoning"):
value = payload.get(key)
if value is None:
continue
text = reasoning_to_text(value)
if text:
return text
return None
def _merge_streamed_value(existing: Any, incoming: Any) -> Any:
if isinstance(existing, str) and isinstance(incoming, str):
if incoming and incoming not in existing:
return f"{existing}{incoming}"
return existing or incoming
return incoming if incoming is not None else existing
def apply_reasoning_from_payload(
message: AIMessage | AIMessageChunk,
payload: dict | None,
*,
append: bool = True,
) -> AIMessage | AIMessageChunk:
"""Copy provider thinking fields onto ``additional_kwargs`` for UI + echo-back."""
if not isinstance(payload, dict):
return message
additional_kwargs = dict(message.additional_kwargs)
changed = False
if "reasoning" in payload and payload.get("reasoning") is not None:
incoming = payload.get("reasoning")
existing = additional_kwargs.get("reasoning")
merged = _merge_streamed_value(existing, incoming) if append else incoming
if merged is not None and merged != existing:
additional_kwargs["reasoning"] = merged
changed = True
text = extract_reasoning_text(payload)
if text:
existing = additional_kwargs.get("reasoning_content")
if append and isinstance(existing, str) and existing:
if text not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{text}"
changed = True
elif text != existing:
additional_kwargs["reasoning_content"] = text
changed = True
if not changed:
return message
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def fill_ui_reasoning_content(message: AIMessage | AIMessageChunk) -> AIMessage | AIMessageChunk:
"""If LangChain stored ``reasoning`` but not the UI field, copy the text across."""
kwargs = message.additional_kwargs
reasoning = kwargs.get("reasoning")
if reasoning is None or kwargs.get("reasoning_content"):
return message
return apply_reasoning_from_payload(message, {"reasoning": reasoning}, append=False)
def restore_reasoning_fields(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject the thinking field this assistant turn originally carried.
Newer vLLM stores thinking on ``reasoning`` and we also copy a UI-facing
``reasoning_content`` string from it. Echo the wire field that actually
arrived: ``reasoning`` wins when present so we do not send a synthesized
``reasoning_content`` to a parser that no longer accepts it. Older
DeepSeek / vLLM turns only have ``reasoning_content`` and stay on that
field.
"""
reasoning = orig_msg.additional_kwargs.get("reasoning")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
return
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
@@ -1,123 +0,0 @@
"""Concise model connection and latency diagnostics for production logs."""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from langchain_core.callbacks import BaseCallbackHandler
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class _CallTiming:
started: float
first_chunk: float | None = None
class ModelTimingCallback(BaseCallbackHandler):
"""Log model-call start, first response chunk, completion, and failure."""
def __init__(self, *, model_name: str, base_url: str | None) -> None:
self._model_name = model_name
self._base_url = base_url or "-"
self._calls: dict[str, _CallTiming] = {}
self._lock = threading.Lock()
def on_chat_model_start(
self,
serialized: dict[str, Any],
messages: list[list[Any]],
*,
run_id: UUID,
**kwargs: Any,
) -> None:
rid = str(run_id)
now = time.monotonic()
with self._lock:
self._calls[rid] = _CallTiming(started=now)
logger.info(
"LLM_CONNECT_START model=%s base_url=%s run_id=%s message_batches=%d message_count=%d",
self._model_name,
self._base_url,
rid,
len(messages),
sum(len(batch) for batch in messages),
)
def on_llm_new_token(
self,
token: str,
*,
run_id: UUID,
chunk: Any | None = None,
**kwargs: Any,
) -> None:
"""Treat the first callback as connection success, including reasoning-only chunks."""
rid = str(run_id)
now = time.monotonic()
with self._lock:
timing = self._calls.get(rid)
if timing is None or timing.first_chunk is not None:
return
timing.first_chunk = now
first_chunk_ms = int((now - timing.started) * 1000)
logger.info(
"LLM_CONNECT_OK model=%s base_url=%s run_id=%s first_chunk_ms=%d",
self._model_name,
self._base_url,
rid,
first_chunk_ms,
)
def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
self._finish(run_id=run_id, error=None)
def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
self._finish(run_id=run_id, error=error)
def _finish(self, *, run_id: UUID, error: BaseException | None) -> None:
rid = str(run_id)
now = time.monotonic()
with self._lock:
timing = self._calls.pop(rid, None)
if timing is None:
logger.error(
"LLM_REQUEST_FAILED model=%s base_url=%s run_id=%s error_type=%s error=%s",
self._model_name,
self._base_url,
rid,
type(error).__name__ if error else "UnknownLifecycle",
error or "missing start event",
)
return
total_ms = int((now - timing.started) * 1000)
first_chunk_ms = int((timing.first_chunk - timing.started) * 1000) if timing.first_chunk else None
if error is None:
logger.info(
"LLM_REQUEST_DONE model=%s base_url=%s run_id=%s status=success first_chunk_ms=%s total_ms=%d",
self._model_name,
self._base_url,
rid,
first_chunk_ms if first_chunk_ms is not None else "none",
total_ms,
)
return
logger.error(
"LLM_REQUEST_FAILED model=%s base_url=%s run_id=%s status=error first_chunk_ms=%s total_ms=%d error_type=%s error=%s",
self._model_name,
self._base_url,
rid,
first_chunk_ms if first_chunk_ms is not None else "none",
total_ms,
type(error).__name__,
error,
)
@@ -6,15 +6,16 @@ from assistant messages and streaming deltas. That breaks interleaved
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
be echoed back on subsequent turns.
This provider preserves ``reasoning`` on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
This provider preserves ``reasoning`` (newer vLLM) and
``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any, cast
@@ -36,6 +37,7 @@ from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
def _reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
if isinstance(reasoning, str):
return reasoning
from deerflow.models.reasoning_fields import reasoning_to_text
if isinstance(reasoning, list):
parts = [_reasoning_to_text(item) for item in reasoning]
return "".join(part for part in parts if part)
if isinstance(reasoning, dict):
for key in ("text", "content", "reasoning"):
value = reasoning.get(key)
if isinstance(value, str):
return value
if value is not None:
text = _reasoning_to_text(value)
if text:
return text
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
return reasoning_to_text(reasoning)
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
@@ -106,12 +86,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
function_call["name"] = ""
additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning")
if reasoning is not None:
additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
additional_kwargs["reasoning_content"] = reasoning_text
seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
tool_call_chunks = []
if raw_tool_calls := _dict.get("tool_calls"):
@@ -151,11 +127,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
reasoning = orig_msg.additional_kwargs.get("reasoning")
if reasoning is None:
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
restore_reasoning_fields(payload_msg, orig_msg)
class VllmChatModel(ChatOpenAI):
@@ -204,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
message = generation.message
if not isinstance(message, AIMessage):
continue
reasoning = choice.get("message", {}).get("reasoning")
if reasoning is None:
continue
message.additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
message.additional_kwargs["reasoning_content"] = reasoning_text
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
generation.message = apply_reasoning_from_payload(
message,
choice_message if isinstance(choice_message, dict) else None,
append=False,
)
return result
@@ -1,197 +0,0 @@
"""User-owned analysis projects and immutable project/thread bindings."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import ForeignKey, String, Text, delete, select
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
MODES = frozenset({"qa", "strategy", "solution"})
CONFIG_FIELDS = frozenset({"agent_id", "report_config", "strategy_config"})
EDITABLE_FIELDS = frozenset({"name", "background", "knowledge_base_ids", "assistant_knowledge_base_ids"}) | CONFIG_FIELDS
class AnalysisProjectConfigRow(Base):
"""Additive table: upgrades do not alter existing project/thread tables."""
__tablename__ = "analysis_project_configs"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
config: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
class AnalysisProjectRow(Base):
__tablename__ = "analysis_projects"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
user_id: Mapped[str] = mapped_column(String(255), index=True)
name: Mapped[str] = mapped_column(String(160))
mode: Mapped[str] = mapped_column(String(16))
background: Mapped[str] = mapped_column(Text, default="")
knowledge_base_ids: Mapped[list] = mapped_column(PortableJSON(), default=list)
assistant_knowledge_base_ids: Mapped[list] = mapped_column(PortableJSON(), default=list)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime())
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime())
class AnalysisProjectThreadRow(Base):
__tablename__ = "analysis_project_threads"
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True)
project_id: Mapped[str] = mapped_column(ForeignKey("analysis_projects.id"), index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime())
def _dict(row):
data = row.to_dict()
for key in ("created_at", "updated_at"):
if isinstance(data.get(key), datetime):
data[key] = data[key].isoformat()
return deepcopy(data)
class AnalysisProjectStore:
"""SQL in persistent deployments; isolated in-memory fallback for memory mode.
Every public project operation requires an explicit owner. The unscoped
thread lookup is only for the Gateway's ownership and run-policy checks.
Mode has no update path, and bindings are separate from client-editable metadata.
"""
def __init__(self, session_factory=None):
self._sf = session_factory
self._projects: dict[str, dict] = {}
self._threads: dict[str, dict] = {}
self._configs: dict[str, dict] = {}
async def config_for(self, key: str) -> dict:
if not self._sf:
return deepcopy(self._configs.get(key, {}))
async with self._sf() as session:
row = await session.get(AnalysisProjectConfigRow, key)
return deepcopy(row.config) if row else {}
async def _enrich(self, project: dict | None) -> dict | None:
if project is None:
return None
return {
**project,
"agent_id": None,
"report_config": {},
"strategy_config": {},
**await self.config_for(f"project:{project['id']}"),
}
async def create(self, user_id: str, *, mode: str, **fields) -> dict:
if mode not in MODES or set(fields) - EDITABLE_FIELDS:
raise ValueError("Invalid project fields")
now = datetime.now(UTC)
row = AnalysisProjectRow(
id=str(uuid4()),
user_id=user_id,
mode=mode,
name=fields["name"],
background=fields.get("background", ""),
knowledge_base_ids=list(fields.get("knowledge_base_ids", [])),
assistant_knowledge_base_ids=list(fields.get("assistant_knowledge_base_ids", [])),
created_at=now,
updated_at=now,
)
if self._sf:
async with self._sf() as session:
session.add(row)
session.add(AnalysisProjectConfigRow(key=f"project:{row.id}", config={key: deepcopy(fields[key]) for key in CONFIG_FIELDS if key in fields}))
await session.commit()
else:
self._projects[row.id] = _dict(row)
self._configs[f"project:{row.id}"] = {key: deepcopy(fields[key]) for key in CONFIG_FIELDS if key in fields}
return await self._enrich(_dict(row))
async def list(self, user_id: str) -> list[dict]:
if not self._sf:
return [await self._enrich(deepcopy(p)) for p in sorted(self._projects.values(), key=lambda p: p["updated_at"], reverse=True) if p["user_id"] == user_id]
async with self._sf() as session:
result = await session.execute(select(AnalysisProjectRow).where(AnalysisProjectRow.user_id == user_id).order_by(AnalysisProjectRow.updated_at.desc()))
rows = [_dict(row) for row in result.scalars()]
return [await self._enrich(row) for row in rows]
async def get(self, project_id: str, user_id: str) -> dict | None:
if not self._sf:
row = self._projects.get(project_id)
return await self._enrich(deepcopy(row)) if row and row["user_id"] == user_id else None
async with self._sf() as session:
row = (await session.execute(select(AnalysisProjectRow).where(AnalysisProjectRow.id == project_id, AnalysisProjectRow.user_id == user_id))).scalar_one_or_none()
data = _dict(row) if row else None
return await self._enrich(data)
async def update(self, project_id: str, user_id: str, **fields) -> dict | None:
if set(fields) - EDITABLE_FIELDS:
raise ValueError("项目模式创建后不可更改")
if not self._sf:
row = self._projects.get(project_id)
if not row or row["user_id"] != user_id:
return None
row.update({key: deepcopy(value) for key, value in fields.items() if key not in CONFIG_FIELDS}, updated_at=datetime.now(UTC).isoformat())
self._configs.setdefault(f"project:{project_id}", {}).update({key: deepcopy(value) for key, value in fields.items() if key in CONFIG_FIELDS})
return await self._enrich(deepcopy(row))
async with self._sf() as session:
row = (await session.execute(select(AnalysisProjectRow).where(AnalysisProjectRow.id == project_id, AnalysisProjectRow.user_id == user_id).with_for_update())).scalar_one_or_none()
if row is None:
return None
for key, value in fields.items():
if key not in CONFIG_FIELDS:
setattr(row, key, deepcopy(value))
options = await session.get(AnalysisProjectConfigRow, f"project:{project_id}")
config = {**(options.config if options else {}), **{key: deepcopy(value) for key, value in fields.items() if key in CONFIG_FIELDS}}
await session.merge(AnalysisProjectConfigRow(key=f"project:{project_id}", config=config))
row.updated_at = datetime.now(UTC)
await session.commit()
data = _dict(row)
return await self._enrich(data)
async def bind_thread(self, project_id: str, user_id: str, thread_id: str, *, agent_id: str | None = None) -> None:
if await self.get(project_id, user_id) is None:
raise ValueError("Project not found")
row = AnalysisProjectThreadRow(thread_id=thread_id, project_id=project_id, created_at=datetime.now(UTC))
if not self._sf:
if thread_id in self._threads:
raise ValueError("Thread already bound")
self._threads[thread_id] = _dict(row)
self._configs[f"thread:{thread_id}"] = {"agent_id": agent_id}
return
async with self._sf() as session:
session.add(row)
session.add(AnalysisProjectConfigRow(key=f"thread:{thread_id}", config={"agent_id": agent_id}))
await session.commit()
async def project_for_thread(self, thread_id: str) -> dict | None:
if not self._sf:
binding = self._threads.get(thread_id)
return await self._enrich(deepcopy(self._projects.get(binding["project_id"]))) if binding else None
async with self._sf() as session:
row = (await session.execute(select(AnalysisProjectRow).join(AnalysisProjectThreadRow).where(AnalysisProjectThreadRow.thread_id == thread_id))).scalar_one_or_none()
data = _dict(row) if row else None
return await self._enrich(data)
async def thread_ids(self, project_id: str, user_id: str) -> list[str]:
if await self.get(project_id, user_id) is None:
return []
if not self._sf:
return [key for key, value in reversed(list(self._threads.items())) if value["project_id"] == project_id]
async with self._sf() as session:
return list((await session.execute(select(AnalysisProjectThreadRow.thread_id).where(AnalysisProjectThreadRow.project_id == project_id).order_by(AnalysisProjectThreadRow.created_at.desc()))).scalars())
async def unbind_failed_creation(self, thread_id: str) -> None:
if not self._sf:
self._threads.pop(thread_id, None)
self._configs.pop(f"thread:{thread_id}", None)
return
async with self._sf() as session:
await session.execute(delete(AnalysisProjectThreadRow).where(AnalysisProjectThreadRow.thread_id == thread_id))
await session.execute(delete(AnalysisProjectConfigRow).where(AnalysisProjectConfigRow.key == f"thread:{thread_id}"))
await session.commit()
@@ -35,7 +35,6 @@ from deerflow.persistence.agents.model import (
AgentRow,
AgentSkillRow,
)
from deerflow.persistence.analysis_projects import AnalysisProjectConfigRow, AnalysisProjectRow, AnalysisProjectThreadRow
from deerflow.persistence.assistant_knowledge.model import (
AssistantKnowledgeBaseRow,
AssistantKnowledgeEntityContributionRow,
@@ -151,7 +150,6 @@ from deerflow.persistence.workflow_runs.model import (
from deerflow.persistence.workflows.model import WorkflowDefinitionRow, WorkflowVersionRow
__all__ = [
"AnalysisProjectConfigRow",
"FeedbackRow",
"FixedQuestionRow",
"AgentExtrasRow",
@@ -162,7 +160,6 @@ __all__ = [
"BusinessMappingRow",
"AssistantKnowledgeBaseRow",
"AssistantKnowledgeSourceRow",
"AssistantKnowledgeVectorRow",
"AssistantKnowledgeImportJobRow",
"AssistantKnowledgeWikiPageRow",
"AssistantKnowledgeWikiRevisionRow",
@@ -262,8 +259,6 @@ __all__ = [
"TagAssignmentRow",
"TagRow",
"TaskButtonRow",
"AnalysisProjectRow",
"AnalysisProjectThreadRow",
"TaskCopTaskRow",
"TaskReportRow",
"EmbedSessionRow",
@@ -180,8 +180,6 @@ def build_writing_research_config(
focus: str | None = None,
thinking_enabled: bool = False,
model_name: str | None = None,
project_mode: str | None = None,
project_report_config: dict | None = None,
) -> DeepResearchConfig:
"""组装聊天写作模式的研究配置:basic + 通用分析结构 + adaptive。
@@ -193,23 +191,12 @@ def build_writing_research_config(
parts: list[str] = []
if focus and focus.strip():
parts.append(focus.strip())
from deerflow.agents.analysis_project import SOLUTION_OUTLINE
fixed_solution = project_mode == "solution"
project_report_config = project_report_config or {}
outline = project_report_config.get("custom_outline") or SOLUTION_OUTLINE
structure_mode = project_report_config.get("structure_mode", "fixed") if fixed_solution else "adaptive"
parts.append(
"遵循项目所选报告结构及固定/弹性约束。引用本项目对话资料;缺失事实、预算、责任人标注待补充,不得编造。"
if fixed_solution else _KNOWLEDGE_WRITE_INSTRUCTION
)
parts.append(_KNOWLEDGE_WRITE_INSTRUCTION)
return DeepResearchConfig(
mode="basic",
collection_mode="legacy",
custom_outline=outline if fixed_solution else GENERAL_OUTLINE,
structure_mode=structure_mode,
retrieval_directions=project_report_config.get("retrieval_directions", []) if fixed_solution else [],
retrieval_skills=project_report_config.get("retrieval_skills", []) if fixed_solution else [],
custom_outline=GENERAL_OUTLINE,
structure_mode="adaptive",
generate_images=False,
thinking_enabled=thinking_enabled,
fast_model=model_name,
@@ -447,9 +434,7 @@ async def deep_research_report_tool(
query = topic.strip()
state = getattr(runtime, "state", None) or {}
conversation_messages = state.get("messages") or []
project_context = (runtime.context or {}).get("analysis_project") or {}
harvest_options = {"include_rag_sources": True} if project_context.get("mode") == "solution" else {}
harvested, _agent_summary = harvest_messages_materials(conversation_messages, query=query, **harvest_options)
harvested, _agent_summary = harvest_messages_materials(conversation_messages, query=query)
# 课题请求之后的检索结果整批保留(那是模型为本课题检索的);只有课题
# 切换前的旧检索才按词面强过滤(旧课题资料不得凑门槛、不得入报告)。
anchor_index = topic_anchor_index(conversation_messages, topic=query)
@@ -491,8 +476,6 @@ async def deep_research_report_tool(
focus=focus,
thinking_enabled=thinking_enabled,
model_name=model_name,
project_mode=project_context.get("mode"),
project_report_config=project_context.get("report_config"),
)
llm_backend = DeerFlowCompletionBackend(
research_config,
@@ -136,13 +136,7 @@ def get_available_tools(
# Memory V2:记忆与 builtin provider 都启用时,暴露 memory 工具给 LLM 主动写入
memory_config = getattr(config, "memory", None)
try:
from deerflow.config.system_settings import get_memory_runtime_settings
memory_runtime_enabled = get_memory_runtime_settings().enabled
except Exception: # noqa: BLE001 - settings failure must keep memory off
memory_runtime_enabled = False
if memory_runtime_enabled and memory_config is not None:
if memory_config is not None and getattr(memory_config, "enabled", False):
builtin_config = getattr(memory_config, "builtin", None)
if builtin_config is not None and getattr(builtin_config, "enabled", False):
builtin_tools.append(memory_tool)
@@ -1,49 +0,0 @@
from types import SimpleNamespace
from deerflow.config import memory_config as memory_config_module
from deerflow.config.memory_config import (
MemoryConfig,
delete_recall_budget_cache,
get_effective_memory_config,
get_memory_config,
set_memory_config,
set_recall_budget_cache,
)
def test_runtime_disable_cannot_be_reactivated_by_config_or_user_override(monkeypatch):
user_id = "memory-disabled-test-user"
original = get_memory_config()
try:
monkeypatch.setattr(
memory_config_module,
"get_memory_runtime_settings",
lambda: SimpleNamespace(enabled=False),
)
set_memory_config(MemoryConfig(enabled=True))
set_recall_budget_cache(user_id, "high")
effective = get_effective_memory_config(user_id)
assert effective.enabled is False
finally:
delete_recall_budget_cache(user_id)
set_memory_config(original)
def test_admin_runtime_enable_activates_memory_even_when_yaml_default_is_off(monkeypatch):
original = get_memory_config()
try:
monkeypatch.setattr(
memory_config_module,
"get_memory_runtime_settings",
lambda: SimpleNamespace(enabled=True),
)
set_memory_config(MemoryConfig(enabled=False, version="v1"))
effective = get_effective_memory_config("memory-enabled-test-user")
assert effective.enabled is True
assert effective.version == "v1"
finally:
set_memory_config(original)
@@ -1,32 +0,0 @@
import logging
from uuid import uuid4
from deerflow.models.timing_callback import ModelTimingCallback
def test_model_timing_callback_logs_connection_and_completion(caplog):
callback = ModelTimingCallback(model_name="qwen", base_url="http://model/v1")
run_id = uuid4()
with caplog.at_level(logging.INFO, logger="deerflow.models.timing_callback"):
callback.on_chat_model_start({}, [[object()]], run_id=run_id)
callback.on_llm_new_token("", run_id=run_id, chunk=object())
callback.on_llm_end(object(), run_id=run_id)
messages = [record.getMessage() for record in caplog.records]
assert any("LLM_CONNECT_START" in message for message in messages)
assert any("LLM_CONNECT_OK" in message and "first_chunk_ms=" in message for message in messages)
assert any("LLM_REQUEST_DONE" in message and "total_ms=" in message for message in messages)
def test_model_timing_callback_logs_failure(caplog):
callback = ModelTimingCallback(model_name="qwen", base_url="http://model/v1")
run_id = uuid4()
with caplog.at_level(logging.INFO, logger="deerflow.models.timing_callback"):
callback.on_chat_model_start({}, [[object()]], run_id=run_id)
callback.on_llm_error(TimeoutError("upstream timeout"), run_id=run_id)
message = caplog.records[-1].getMessage()
assert "LLM_REQUEST_FAILED" in message
assert "TimeoutError" in message
@@ -1,40 +0,0 @@
from langchain_core.messages import AIMessage, AIMessageChunk
from deerflow.models.patched_openai import (
_append_reasoning_content,
_extract_reasoning_content,
_restore_reasoning_content,
)
def test_extracts_legacy_reasoning_content():
assert _extract_reasoning_content({"reasoning_content": "legacy"}) == "legacy"
def test_extracts_vllm_reasoning():
assert _extract_reasoning_content({"reasoning": "new spelling"}) == "new spelling"
def test_normalizes_vllm_reasoning_for_existing_clients():
message = AIMessageChunk(content="")
updated = _append_reasoning_content(message, "thinking", reasoning="thinking")
assert updated.additional_kwargs["reasoning_content"] == "thinking"
assert updated.additional_kwargs["reasoning"] == "thinking"
def test_restores_original_vllm_reasoning_spelling_for_follow_up_turn():
original = AIMessage(
content="answer",
additional_kwargs={
"reasoning": "thinking",
"reasoning_content": "thinking",
},
)
payload = {"role": "assistant", "content": "answer"}
_restore_reasoning_content(payload, original)
assert payload["reasoning"] == "thinking"
assert "reasoning_content" not in payload
@@ -1,57 +0,0 @@
#!/usr/bin/env python3
"""Summarize LLM_CONNECT_* records from a DeerFlow backend log."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
RUN_RE = re.compile(r"run_id=([^ ]+)")
VALUE_RE = re.compile(r"(first_chunk_ms|total_ms)=([^ ]+)")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("log", nargs="?", type=Path, help="Log file; omit to read stdin")
args = parser.parse_args()
text = args.log.read_text(encoding="utf-8", errors="replace") if args.log else sys.stdin.read()
calls: dict[str, dict[str, str]] = {}
for line in text.splitlines():
if "LLM_" not in line:
continue
run_match = RUN_RE.search(line)
if not run_match:
continue
run_id = run_match.group(1)
row = calls.setdefault(run_id, {"run_id": run_id, "status": "started"})
if "LLM_CONNECT_OK" in line:
row["status"] = "connected"
if "LLM_REQUEST_DONE" in line:
row["status"] = "success"
if "LLM_REQUEST_FAILED" in line:
row["status"] = "failed"
for key, value in VALUE_RE.findall(line):
row[key] = value
print("run_id status first_chunk_ms total_ms")
for row in calls.values():
print(
row["run_id"],
row.get("status", "-"),
row.get("first_chunk_ms", "-"),
row.get("total_ms", "-"),
)
succeeded = sum(row.get("status") == "success" for row in calls.values())
failed = sum(row.get("status") == "failed" for row in calls.values())
unfinished = len(calls) - succeeded - failed
print(f"SUMMARY calls={len(calls)} success={succeeded} failed={failed} unfinished={unfinished}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""Measure DeerFlow model-factory latency without exposing prompts or secrets."""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any
from langchain_core.messages import HumanMessage
from deerflow.config import get_app_config
from deerflow.models import create_chat_model
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Diagnose DeerFlow LLM first-chunk and completion latency")
parser.add_argument("--model", required=True, help="DeerFlow config model name")
parser.add_argument("--prompt", default="请先思考,然后只回答:17乘以23等于多少?")
parser.add_argument("--prompt-file", type=Path, help="UTF-8 prompt file; content is never printed")
parser.add_argument("--repeat", type=int, default=1, help="Repeat the selected prompt N times")
parser.add_argument("--max-tokens", type=int, default=256)
parser.add_argument("--thinking", action=argparse.BooleanOptionalAction, default=True)
return parser.parse_args()
def _reasoning_text(chunk: Any) -> str:
kwargs = getattr(chunk, "additional_kwargs", None) or {}
value = kwargs.get("reasoning_content") or kwargs.get("reasoning")
return value if isinstance(value, str) else ""
def _content_text(chunk: Any) -> str:
content = getattr(chunk, "content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(part.get("text", "") for part in content if isinstance(part, dict))
return str(content or "")
def main() -> int:
args = _args()
prompt = args.prompt_file.read_text(encoding="utf-8") if args.prompt_file else args.prompt
prompt *= max(args.repeat, 1)
config = get_app_config()
model_config = config.get_model_config(args.model)
if model_config is None:
print(f"ERROR model_not_found={args.model}", file=sys.stderr)
return 2
print(
"CONFIG "
+ json.dumps(
{
"name": model_config.name,
"use": model_config.use,
"base_url": str(model_config.base_url or ""),
"request_timeout": model_config.request_timeout,
"max_retries": model_config.max_retries,
"supports_thinking": model_config.supports_thinking,
"when_thinking_enabled": model_config.when_thinking_enabled,
"prompt_chars": len(prompt),
},
ensure_ascii=False,
),
flush=True,
)
build_started = time.monotonic()
model = create_chat_model(args.model, thinking_enabled=args.thinking)
build_ms = int((time.monotonic() - build_started) * 1000)
print(
"MODEL "
+ json.dumps(
{
"runtime_class": f"{type(model).__module__}.{type(model).__name__}",
"build_ms": build_ms,
}
),
flush=True,
)
messages = [HumanMessage(content=prompt)]
if hasattr(model, "_get_request_payload"):
payload = model._get_request_payload(messages, max_tokens=args.max_tokens)
safe_payload = dict(payload)
safe_payload["messages"] = [
{
"role": item.get("role"),
"content_chars": len(str(item.get("content") or "")),
"has_tools": bool(item.get("tool_calls")),
}
for item in payload.get("messages", [])
]
print("PAYLOAD " + json.dumps(safe_payload, ensure_ascii=False, default=str), flush=True)
started = time.monotonic()
first_chunk_ms: int | None = None
first_reasoning_ms: int | None = None
first_content_ms: int | None = None
chunks = 0
reasoning_chars = 0
content_chars = 0
usage: dict[str, Any] = {}
try:
for chunk in model.stream(messages, max_tokens=args.max_tokens):
chunks += 1
now_ms = int((time.monotonic() - started) * 1000)
if first_chunk_ms is None:
first_chunk_ms = now_ms
print(f"FIRST_CHUNK elapsed_ms={now_ms}", flush=True)
reasoning = _reasoning_text(chunk)
content = _content_text(chunk)
if reasoning:
reasoning_chars += len(reasoning)
if first_reasoning_ms is None:
first_reasoning_ms = now_ms
print(f"FIRST_REASONING elapsed_ms={now_ms}", flush=True)
if content:
content_chars += len(content)
if first_content_ms is None:
first_content_ms = now_ms
print(f"FIRST_CONTENT elapsed_ms={now_ms}", flush=True)
chunk_usage = getattr(chunk, "usage_metadata", None)
if chunk_usage:
usage = dict(chunk_usage)
except Exception as exc:
total_ms = int((time.monotonic() - started) * 1000)
print(
"FAILED "
+ json.dumps(
{"total_ms": total_ms, "error_type": type(exc).__name__, "error": str(exc)},
ensure_ascii=False,
),
file=sys.stderr,
flush=True,
)
return 1
total_ms = int((time.monotonic() - started) * 1000)
print(
"RESULT "
+ json.dumps(
{
"status": "success",
"first_chunk_ms": first_chunk_ms,
"first_reasoning_ms": first_reasoning_ms,
"first_content_ms": first_content_ms,
"total_ms": total_ms,
"chunks": chunks,
"reasoning_chars": reasoning_chars,
"content_chars": content_chars,
"usage": usage,
},
ensure_ascii=False,
),
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,227 +0,0 @@
"""Project ownership, immutable mode, run policy, and fixed-template writing."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway import analysis_projects as policy
from app.gateway.routers import analysis_projects as routes
from app.gateway.routers.thread_runs import RunCreateRequest
from deerflow.agents.analysis_project import SOLUTION_OUTLINE, project_system_prompt
from deerflow.agents.deep_research.collection.thread_harvester import harvest_messages_materials
from deerflow.persistence.analysis_projects import AnalysisProjectConfigRow, AnalysisProjectRow, AnalysisProjectStore, AnalysisProjectThreadRow
from deerflow.persistence.base import Base
from deerflow.tools.builtins.deep_research_report_tool import build_writing_research_config
def run(coro):
return asyncio.run(coro)
@pytest.mark.parametrize("persistent", [False, True])
def test_store_ownership_mode_and_binding_survive_updates(tmp_path, persistent):
async def scenario():
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'projects.db'}") if persistent else None
sf = async_sessionmaker(engine, expire_on_commit=False) if engine else None
if engine:
async with engine.begin() as connection:
await connection.run_sync(lambda conn: Base.metadata.create_all(conn, tables=[AnalysisProjectRow.__table__, AnalysisProjectThreadRow.__table__, AnalysisProjectConfigRow.__table__]))
try:
store = AnalysisProjectStore(sf)
project = await store.create("alice", mode="qa", name="项目 A", knowledge_base_ids=["kb-a"], agent_id="agent-a", report_config={"custom_outline": "## 自选结构"}, strategy_config={"lenses": ["风险视角", "用户体验视角"]})
other = await store.create("bob", mode="solution", name="项目 B")
await store.bind_thread(project["id"], "alice", "thread-a")
await store.bind_thread(other["id"], "bob", "thread-b")
assert await store.get(project["id"], "bob") is None
assert await store.update(project["id"], "bob", name="伪造") is None
with pytest.raises(ValueError):
await store.update(project["id"], "alice", mode="solution")
with pytest.raises(ValueError):
await store.bind_thread(other["id"], "alice", "foreign")
await store.update(project["id"], "alice", knowledge_base_ids=["kb-b"])
reloaded = AnalysisProjectStore(sf) if sf else store
assert (await reloaded.project_for_thread("thread-a"))["mode"] == "qa"
assert (await reloaded.get(project["id"], "alice"))["knowledge_base_ids"] == ["kb-b"]
assert (await reloaded.get(project["id"], "alice"))["agent_id"] == "agent-a"
assert (await reloaded.get(project["id"], "alice"))["report_config"]["custom_outline"] == "## 自选结构"
assert (await reloaded.get(project["id"], "alice"))["strategy_config"]["lenses"] == ["风险视角", "用户体验视角"]
assert await reloaded.thread_ids(project["id"], "bob") == []
assert await reloaded.thread_ids(project["id"], "alice") == ["thread-a"]
assert [p["id"] for p in await reloaded.list("bob")] == [other["id"]]
finally:
if engine:
await engine.dispose()
run(scenario())
@pytest.fixture
def client(monkeypatch):
app = FastAPI()
app.state.analysis_project_store = AnalysisProjectStore()
app.state.llmwiki_store = SimpleNamespace(get_authorized=AsyncMock(side_effect=lambda kb, user, **kwargs: {"id": kb} if kb in {"wiki-a", "wiki-b"} else None))
app.state.assistant_knowledge_store = SimpleNamespace(get_base=AsyncMock(side_effect=lambda kb: {"id": kb} if kb == "assistant-a" else None))
async def actor(request):
return request.headers.get("x-test-user", "alice"), False
monkeypatch.setattr(policy, "actor", actor)
monkeypatch.setattr(routes, "actor", actor)
monkeypatch.setattr(policy, "get_agent_store", lambda request: SimpleNamespace(get_visible=AsyncMock(side_effect=lambda agent_id, user_id: {"id": agent_id} if agent_id in {"agent-a", "agent-b"} else None)))
app.include_router(routes.router)
with TestClient(app) as client:
yield client
def test_project_api_mode_not_editable_and_other_owners_hidden(client):
response = client.post("/api/analysis-projects", json={"name": "项目", "mode": "strategy", "knowledge_base_ids": ["wiki-a", "wiki-a"]})
assert response.status_code == 201
project = response.json()
assert project["knowledge_base_ids"] == ["wiki-a"]
assert "user_id" not in project
url = f"/api/analysis-projects/{project['id']}"
assert client.put(url, json={"name": "变更", "mode": "solution"}).status_code == 422
updated = client.put(url, json={"name": "变更", "knowledge_base_ids": ["wiki-b"], "assistant_knowledge_base_ids": ["assistant-a"]})
assert updated.status_code == 200
assert updated.json()["mode"] == "strategy"
assert client.get(url, headers={"x-test-user": "bob"}).status_code == 404
assert client.put(url, headers={"x-test-user": "bob"}, json={"name": "他人修改"}).status_code == 404
assert client.get("/api/analysis-projects", headers={"x-test-user": "bob"}).json() == {"projects": []}
assert client.post(f"{url}/threads", headers={"x-test-user": "bob"}).status_code == 404
assert client.put(url, json={"name": "变更", "knowledge_base_ids": ["private-or-deleted"]}).status_code == 422
assert client.post("/api/analysis-projects", json={"name": "", "mode": "qa"}).status_code == 422
assert client.post("/api/analysis-projects", json={"name": "自主分析", "mode": "strategy"}).status_code == 201
assert client.post("/api/analysis-projects", json={"name": "不可用", "mode": "qa", "agent_id": "private"}).status_code == 422
@pytest.mark.parametrize("mode", ["qa", "strategy", "solution"])
def test_run_policy_uses_binding_not_client_mode_or_knowledge(monkeypatch, mode):
async def scenario():
store = AnalysisProjectStore()
project = await store.create("alice", mode=mode, name="项目", knowledge_base_ids=["wiki-a"])
await store.bind_thread(project["id"], "alice", "thread", agent_id="agent-a")
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(analysis_project_store=store)))
monkeypatch.setattr(policy, "actor", AsyncMock(return_value=("alice", False)))
monkeypatch.setattr(policy, "validate_knowledge", AsyncMock())
monkeypatch.setattr(policy, "validate_project_agent", AsyncMock())
monkeypatch.setattr(policy, "load_agent_config", lambda agent_id: SimpleNamespace(llmwiki_knowledge_base_ids=["wiki-a"]))
checkpointer = SimpleNamespace(aget_tuple=AsyncMock(return_value=None))
monkeypatch.setattr(policy, "get_checkpointer", lambda request: checkpointer)
body = RunCreateRequest(
assistant_id="ai_writing",
context={"writing_mode": True, "analysis_project": {"mode": "solution"}, "llmwiki_knowledge_base_ids": ["forged"]},
config={"configurable": {"analysis_project": {"mode": "solution"}, "writing_mode": True, "is_writing_setup": True}},
)
prepared, resolved = await policy.prepare_project_run(body, "thread", request)
assert resolved["id"] == project["id"]
assert prepared.assistant_id == "agent-a"
assert prepared.context["agent_id"] == "agent-a"
assert prepared.context["analysis_project"]["mode"] == mode
assert prepared.context["writing_mode"] == (mode == "solution")
assert prepared.context["llmwiki_knowledge_base_ids"] == ["wiki-a"]
assert prepared.config["configurable"]["is_writing_setup"] is False
assert body.context["llmwiki_knowledge_base_ids"] == ["forged"]
await store.update(project["id"], "alice", agent_id="agent-b", report_config={"custom_outline": "## 新结构", "structure_mode": "adaptive"})
prepared, _ = await policy.prepare_project_run(body, "thread", request)
assert prepared.context["llmwiki_knowledge_base_ids"] == ["wiki-a"]
assert prepared.context["agent_id"] == "agent-a"
assert prepared.context["analysis_project"]["report_config"]["custom_outline"] == "## 新结构"
ordinary, resolved = await policy.prepare_project_run(body, "ordinary", request)
assert resolved is None
assert "analysis_project" not in ordinary.context
assert "analysis_project" not in ordinary.config["configurable"]
monkeypatch.setattr(policy, "actor", AsyncMock(return_value=("bob", False)))
with pytest.raises(HTTPException) as error:
await policy.prepare_project_run(body, "thread", request)
assert error.value.status_code == 404
run(scenario())
def test_fixed_solution_template_and_retrieved_materials():
assert build_writing_research_config().structure_mode == "adaptive"
config = build_writing_research_config(project_mode="solution")
assert config.structure_mode == "fixed"
assert config.custom_outline == SOLUTION_OUTLINE
selected = build_writing_research_config(project_mode="solution", project_report_config={"custom_outline": "## 目标\n## 路径", "structure_mode": "adaptive", "retrieval_directions": ["市场"], "retrieval_skills": ["search"]})
assert selected.custom_outline == "## 目标\n## 路径"
assert selected.structure_mode == "adaptive"
assert selected.retrieval_directions == ["市场"]
assert selected.retrieval_skills == ["search"]
assert "不得编造" in config.report_instruction
assert project_system_prompt({}) == ""
strategy_prompt = project_system_prompt({"analysis_project": {"mode": "strategy", "strategy_config": {"lenses": ["风险视角", "用户体验视角"]}}})
assert "策略智能" in strategy_prompt
assert "策略辩论" in strategy_prompt
assert "## 完整策略方案" in strategy_prompt
assert "### 分阶段行动路线图" in strategy_prompt
assert "### 评估指标与复盘机制" in strategy_prompt
assert "### 备选策略与切换条件" in strategy_prompt
assert "用户体验视角" in strategy_prompt
source = {"title": "渠道调研", "content": "华东市场已有三个渠道伙伴", "url": "https://example.test/source"}
message = HumanMessage(content="生成区域市场方案", additional_kwargs={"llmwiki_reference_sources": [source]})
materials, _ = harvest_messages_materials([message], query="区域市场", include_rag_sources=True)
assert len(materials) == 1
assert "三个渠道伙伴" in materials[0].raw_content
assert materials[0].metadata["message_index"] == 0
def test_project_thread_creation_binding_and_failed_creation_cleanup(client, monkeypatch):
rows = {}
async def get(thread_id, **kwargs):
return rows.get(thread_id)
async def delete(thread_id):
rows.pop(thread_id, None)
async def update_metadata(thread_id, metadata, **kwargs):
rows[thread_id]["metadata"] = metadata
thread_store = SimpleNamespace(get=get, delete=delete, update_metadata=update_metadata)
checkpoint = SimpleNamespace(adelete_thread=AsyncMock())
monkeypatch.setattr(routes, "get_thread_store", lambda request: thread_store)
monkeypatch.setattr(routes, "get_checkpointer", lambda request: checkpoint)
async def create(body, request):
rows[body.thread_id] = {"thread_id": body.thread_id, "status": "idle", "metadata": body.metadata}
return rows[body.thread_id]
monkeypatch.setattr(routes, "create_thread", create)
project = client.post("/api/analysis-projects", json={"name": "方案项目", "mode": "solution", "agent_id": "agent-a"}).json()
other = client.post("/api/analysis-projects", json={"name": "问答项目", "mode": "qa"}).json()
url = f"/api/analysis-projects/{project['id']}/threads"
created = client.post(url)
assert created.status_code == 201
assert created.json()["metadata"]["agent_id"] == "agent-a"
assert created.json()["metadata"]["system"] is False
thread_id = created.json()["thread_id"]
assert client.get(url).json()["threads"] == [{**created.json(), "title": "新对话"}]
assert client.get(f"{url}/{thread_id}").status_code == 200
assert client.get(f"{url}/{thread_id}", headers={"x-test-user": "bob"}).status_code == 404
assert client.get(f"/api/analysis-projects/{other['id']}/threads/{thread_id}").status_code == 404
assert run(client.app.state.analysis_project_store.project_for_thread(thread_id))["mode"] == "solution"
second = client.post(url, json={"agent_id": "agent-b"})
assert second.status_code == 201
assert run(client.app.state.analysis_project_store.config_for(f"thread:{second.json()['thread_id']}"))["agent_id"] == "agent-b"
assert client.post(url, json={"agent_id": "private"}).status_code == 422
failed_ids = []
async def fail_after_meta(body, request):
await create(body, request)
failed_ids.append(body.thread_id)
raise HTTPException(500, "Checkpoint unavailable")
monkeypatch.setattr(routes, "create_thread", fail_after_meta)
assert client.post(url).status_code == 500
failed_id = failed_ids[0]
assert failed_id not in rows
assert run(client.app.state.analysis_project_store.project_for_thread(failed_id)) is None
checkpoint.adelete_thread.assert_awaited_once_with(failed_id)
@@ -239,3 +239,67 @@ def test_force_disable_thinking_skips_generic_openai_models_without_thinking_sup
)
assert chat_model.extra_body is None
def _extra_body(chat_model) -> dict:
body = chat_model.extra_body or {}
return body if isinstance(body, dict) else dict(body)
def test_qwen38_flash_mode_disables_default_xhigh_thinking() -> None:
"""Qwen3.8 defaults to thinking=xhigh; flash must send enable_thinking=false."""
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="Qwen/Qwen3.8-27B",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=False,
)
]
)
chat_model = create_chat_model("qwen3.8-27b", thinking_enabled=False, app_config=config)
extra_body = _extra_body(chat_model)
assert extra_body.get("enable_thinking") is False
assert extra_body.get("chat_template_kwargs", {}).get("enable_thinking") is False
def test_qwen38_thinking_mode_sends_medium_reasoning_effort() -> None:
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen3.8-27b",
use="langchain_openai:ChatOpenAI",
model="qwen3.8-27b",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=True,
)
]
)
chat_model = create_chat_model(
"qwen3.8-27b",
thinking_enabled=True,
reasoning_effort="medium",
app_config=config,
)
extra_body = _extra_body(chat_model)
kwargs = extra_body.get("chat_template_kwargs") or {}
assert extra_body.get("enable_thinking") is True
assert kwargs.get("enable_thinking") is True
assert kwargs.get("reasoning_effort") == "medium"
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
from deerflow.models.patched_openai import _extract_reasoning_content
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理"
assert _extract_reasoning_content({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
assert _extract_reasoning_content({"content": "答案"}) is None
@@ -33,7 +33,6 @@ def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None:
assert settings.prompt_prefix.ordinary_qa_markdown_format_enabled is False
assert settings.citation_display.reference_mode_enabled is False
assert settings.citation_display.cleanup_words == []
assert settings.memory.enabled is False
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
@@ -140,22 +139,6 @@ async def test_citation_display_update_preserves_unspecified_fields(
assert payload["citation_display"]["cleanup_words"] == ["广告", "免责声明"]
@pytest.mark.asyncio
async def test_admin_can_enable_memory_runtime_switch(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
response = await ss_router.update_memory_runtime_setting(
ss_router.MemoryRuntimeUpdateRequest(enabled=True),
SimpleNamespace(),
)
assert response.enabled is True
assert ss.load_system_settings().memory.enabled is True
def test_prepend_prefix_tags_first_human_without_touching_content() -> None:
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
@@ -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"
@@ -8,7 +8,7 @@ import pytest
from fastapi import FastAPI, UploadFile
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.routers import assistant_knowledge, wiki_packages
from app.gateway.routers import wiki_packages
from deerflow.assistant_knowledge.archive import PackageArchive
from deerflow.assistant_knowledge.package_io import write_package
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig
@@ -22,26 +22,6 @@ from deerflow.persistence.base import Base
from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore
@pytest.mark.asyncio
async def test_existing_wiki_vectors_can_be_exported_without_runtime_encoder(monkeypatch):
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(llmwiki_sync_service=None),
),
)
async def has_vectors(_request, mapping_id):
assert mapping_id == "mapping-1"
return True
monkeypatch.setattr(assistant_knowledge, "_has_local_wiki_vectors_for_export", has_vectors)
await assistant_knowledge._ensure_local_wiki_vectors_for_export(
request,
{"id": "mapping-1", "weknora_id": "remote-1"},
)
@pytest.mark.asyncio
async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+1 -1
View File
@@ -291,7 +291,7 @@ ai_writing:
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory:
enabled: true
enabled: false # 临时关闭跨会话记忆(提取/注入/Hindsight 召回与 retain、memory 工具)
version: v1
provider: hindsight # 'builtin' | 'hindsight'(外部 provider 名)
# builtin 始终启用,这里只决定 external 选哪个