Compare commits

..
7 Commits
Author SHA1 Message Date
Administrator 5d6627af50 feat: 完善策略优选项目工作流 2026-09-09 20:49:59 +08:00
Administrator a26fd32eba feat: add unified strategy project workflow 2026-09-09 15:40:29 +08:00
Administrator 67d12929fc Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	offline-backend-20260512/backend/app/gateway/app.py
2026-09-09 10:00:24 +08:00
Administrator 5fd2e8302c feat: add strategy selection workbench 2026-09-09 01:08:11 +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
97 changed files with 501 additions and 5373 deletions

No files matched your search

-1
View File
@@ -112,7 +112,6 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py !offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py
!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py !offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py
!offline-backend-20260512/backend/tests/test_llmwiki_encoder_runtime.py
!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py !offline-backend-20260512/backend/tests/test_external_llmwiki_search.py
!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py !offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py
!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py !offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py
+1 -1
View File
@@ -18,7 +18,7 @@ VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 # 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。
VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true
# 轻应用「新建笔记」系列接口 # 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
-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 存储、跨用户隔离、禁止修改模式、运行时覆盖伪造模式与知识库、更新配置、对话归属与失败清理、固定方案模板及知识库资料进入研究管线。
部署前还需使用实际知识库和模型验证三类问答、流式断线续接及方案后续修改。接口模拟与单元测试不能替代真实模型联调。
@@ -152,7 +152,6 @@ export function AgentDetailPanel({
positionName, positionName,
skillsIcon, skillsIcon,
skillsTitle, skillsTitle,
belowSkillsSlot,
showCommonQuestions = false, showCommonQuestions = false,
jobResponsibilities, jobResponsibilities,
panelTitle, panelTitle,
@@ -182,8 +181,6 @@ export function AgentDetailPanel({
positionName?: string; positionName?: string;
skillsIcon?: ReactNode; skillsIcon?: ReactNode;
skillsTitle?: string; skillsTitle?: string;
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
belowSkillsSlot?: ReactNode;
showCommonQuestions?: boolean; showCommonQuestions?: boolean;
/** /**
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示; * 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
@@ -943,9 +940,6 @@ export function AgentDetailPanel({
/> />
</Section> </Section>
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
{belowSkillsSlot}
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />} {showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
</div> </div>
@@ -83,12 +83,10 @@ const ChatBox: React.FC<{
children: React.ReactNode; children: React.ReactNode;
threadId: string; threadId: string;
disableArtifacts?: boolean; disableArtifacts?: boolean;
writingMode?: boolean;
}> = ({ }> = ({
children, children,
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
writingMode,
}) => { }) => {
const { const {
isMock, isMock,
@@ -116,7 +114,7 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode; const writingModeEnabled = !!settings.context.writing_mode;
const artifactDirectoryModeEnabled = const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false; settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings(); const { data: citationDisplay } = useCitationDisplaySettings();
@@ -2,6 +2,7 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon,
BookMarkedIcon, BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
@@ -142,6 +143,7 @@ export function InputBox({
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
hideContextTools, hideContextTools,
hideMemoryTool = true,
...props ...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & { }: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null; assistantId?: string | null;
@@ -149,9 +151,14 @@ export function InputBox({
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框, /** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */ * 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
compact?: boolean; compact?: boolean;
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等 /** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
* 受控场景用;文件/模型等其他按钮不受影响)。 */ * 受控场景用;文件/模型等其他按钮不受影响)。 */
hideContextTools?: boolean; hideContextTools?: boolean;
/**
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
* 写作和参考文献开关不受影响。
*/
hideMemoryTool?: boolean;
disabled?: boolean; disabled?: boolean;
context: Omit< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -160,6 +167,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}; };
extraHeader?: React.ReactNode; extraHeader?: React.ReactNode;
isNewThread?: boolean; isNewThread?: boolean;
@@ -176,6 +184,7 @@ export function InputBox({
mode: "flash" | "thinking" | "pro" | "ultra" | undefined; mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
memory_injection_enabled?: boolean;
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onFollowupsVisibilityChange?: (visible: boolean) => void;
@@ -388,6 +397,14 @@ export function InputBox({
}); });
}, [artifactDirectoryModeEnabled, setLocalSettings]); }, [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 ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
const handleRagModeToggle = useCallback(() => { const handleRagModeToggle = useCallback(() => {
@@ -796,6 +813,32 @@ export function InputBox({
<span className="text-xs font-normal">文件</span> <span className="text-xs font-normal">文件</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
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 && !frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
@@ -373,8 +373,6 @@ export function LlmWikiKnowledgeSelector({
readOnly = false, readOnly = false,
className, className,
label = "知识空间", label = "知识空间",
panelTitle = "绑定知识空间",
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
variant = "popover", variant = "popover",
}: { }: {
value: string[]; value: string[];
@@ -389,8 +387,6 @@ export function LlmWikiKnowledgeSelector({
readOnly?: boolean; readOnly?: boolean;
className?: string; className?: string;
label?: string; label?: string;
panelTitle?: string;
panelDescription?: string;
variant?: SelectorVariant; variant?: SelectorVariant;
}) { }) {
const [open, setOpen] = useState(false); 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 className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div> <div>
<div className="flex items-center gap-2 text-sm font-semibold"> <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> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{panelDescription} 新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p> </p>
</div> </div>
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge> <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"; "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 { useEffect, useMemo, useState } from "react";
import { import {
@@ -13,7 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-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 { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -61,12 +61,15 @@ export function SettingsDialog(props: SettingsDialogProps) {
// }, // },
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, // { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon }, // { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
...(isAdmin // 记忆已移至侧边栏入口 (/page/workspace/memory)
? [{ id: "memory", label: "问答记忆", icon: BrainIcon }] // {
: []), // id: "memory",
// label: t.settings.sections.memory,
// icon: BrainIcon,
// },
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon }, { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
...(isAdmin ...(isAdmin
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }] ? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
: []), : []),
{ {
id: "account", id: "account",
@@ -129,9 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
{activeSection === "appearance" && ( {activeSection === "appearance" && (
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} /> <AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
)} )}
{activeSection === "memory" && isAdmin && ( {activeSection === "memory" && <MemorySettingsPage />}
<MemoryRuntimeSettingsPage />
)}
{activeSection === "tools" && <ToolSettingsPage />} {activeSection === "tools" && <ToolSettingsPage />}
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />} {activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
{activeSection === "about" && <AboutSettingsPage />} {activeSection === "about" && <AboutSettingsPage />}
@@ -24,10 +24,7 @@ import {
applyCompactMenuOverrides, applyCompactMenuOverrides,
type CompactMenuItem, type CompactMenuItem,
} from "@/core/page-layout/compact-menu-overrides"; } from "@/core/page-layout/compact-menu-overrides";
import { import { type MenuItem } from "@/core/page-layout/sidebar-menu";
MARKDOWN_DOCX_MENU_ITEM,
type MenuItem,
} from "@/core/page-layout/sidebar-menu";
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides"; import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -54,13 +51,6 @@ export type WorkspaceCompactNavItem = MenuItem & {
}; };
export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ 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", id: "workspace-new-chat",
label: "新对话", label: "新对话",
@@ -105,6 +95,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/skills", path: "/page/workspace/skills",
match: (pathname) => pathname.startsWith("/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", id: "workspace-ai-writing",
label: "AI写作", label: "AI写作",
@@ -152,13 +149,7 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
match: (pathname) => match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
pathname.startsWith("/page/workspace/knowledge") &&
!pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
},
{
...MARKDOWN_DOCX_MENU_ITEM,
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
}, },
]; ];
@@ -35,7 +35,6 @@ export function WorkspaceSidebar({
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是 // 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。 // 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
const isGeneralQa = const isGeneralQa =
pathname.startsWith("/page/workspace/projects") ||
pathname.startsWith("/page/workspace/chats") || pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/strategy/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;
}
+11 -21
View File
@@ -364,18 +364,12 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return null; return null;
} }
if ( if (
message.additional_kwargs && message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs "reasoning_content" in message.additional_kwargs
) { ) {
return message.additional_kwargs.reasoning_content as string | null; return message.additional_kwargs.reasoning_content as string | null;
} }
if (
message.additional_kwargs &&
typeof message.additional_kwargs.reasoning === "string"
) {
return message.additional_kwargs.reasoning;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
if (part && "thinking" in part) { if (part && "thinking" in part) {
@@ -391,9 +385,8 @@ export function extractReasoningContentFromMessage(message: Message) {
export function removeReasoningContentFromMessage(message: Message) { export function removeReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) { if (message.type !== "ai" || !message.additional_kwargs) {
return; return;
} }
delete message.additional_kwargs.reasoning_content; delete message.additional_kwargs.reasoning_content;
delete message.additional_kwargs.reasoning;
} }
export function extractURLFromImageURLContent( export function extractURLFromImageURLContent(
@@ -428,12 +421,9 @@ export function hasReasoning(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return false; return false;
} }
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
@@ -49,23 +49,12 @@ export type MenuItem = {
path?: string; path?: string;
/** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */ /** 额外视为「当前页属于本菜单项」的路由(不在侧栏展示,仅用于高亮)。 */
aliasPaths?: string[]; aliasPaths?: string[];
/** 从当前菜单项自身的 path / aliasPaths 前缀匹配中排除的路由。 */
excludePaths?: string[];
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */ /** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string; userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
children?: MenuItem[]; children?: MenuItem[];
}; };
/** Markdown 转 Word 功能的菜单定义,供全量与简洁模式共用。 */
export const MARKDOWN_DOCX_MENU_PATH = "/page/workspace/knowledge/markdown-editor";
export const MARKDOWN_DOCX_MENU_ITEM: MenuItem & { path: string } = {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: MARKDOWN_DOCX_MENU_PATH,
};
export const sidebarMenuItems: MenuItem[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
@@ -73,7 +62,6 @@ export const sidebarMenuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { 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: "artifact-library", label: "资料库", icon: Library, path: "/page/workspace/library" },
{ {
id: "multi-agent-roundtable", id: "multi-agent-roundtable",
@@ -278,7 +266,12 @@ export const sidebarMenuItems: MenuItem[] = [
}, },
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" }, { id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
MARKDOWN_DOCX_MENU_ITEM, {
id: "resource-markdown-docx-editor",
label: "md转word",
icon: FileText,
path: "/page/workspace/knowledge/markdown-editor",
},
{ {
id: "mgmt-tasks", id: "mgmt-tasks",
label: "定时任务", label: "定时任务",
@@ -345,7 +338,6 @@ export const sidebarMenuItems: MenuItem[] = [
label: "知识库管理", label: "知识库管理",
icon: BookOpen, icon: BookOpen,
path: "/page/workspace/knowledge", path: "/page/workspace/knowledge",
excludePaths: [MARKDOWN_DOCX_MENU_PATH],
}, },
{ {
id: "resource-knowledge-base", id: "resource-knowledge-base",
@@ -381,9 +373,6 @@ function routeMatchesPathname(pathname: string, route?: string): boolean {
/** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */ /** 菜单项自身 path / aliasPaths 是否匹配当前路由。 */
export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean { export function menuItemMatchesPath(item: MenuItem, pathname: string): boolean {
if (item.excludePaths?.some((path) => routeMatchesPathname(pathname, path))) {
return false;
}
if (routeMatchesPathname(pathname, item.path)) return true; if (routeMatchesPathname(pathname, item.path)) return true;
return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false; return item.aliasPaths?.some((p) => routeMatchesPathname(pathname, p)) ?? false;
} }
@@ -601,48 +601,6 @@ export interface ModelConcurrencyUpdate {
user_model_limits?: Record<string, number>; 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> { export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/skill-compression`, `${getBackendBaseURL()}/api/system-settings/skill-compression`,
@@ -8,7 +8,6 @@ import {
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
loadModelConcurrencySettings, loadModelConcurrencySettings,
loadMemoryRuntimeSettings,
loadPluginManagementSettings, loadPluginManagementSettings,
loadPrivateSquareStatus, loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
@@ -20,7 +19,6 @@ import {
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
updateModelConcurrencySettings, updateModelConcurrencySettings,
updateMemoryRuntimeSettings,
updatePluginManagementSettings, updatePluginManagementSettings,
updatePrivateSquareSettings, updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
@@ -31,7 +29,6 @@ import {
type CitationDisplayUpdate, type CitationDisplayUpdate,
type LeaderboardSettingsUpdate, type LeaderboardSettingsUpdate,
type ModelConcurrencyUpdate, type ModelConcurrencyUpdate,
type MemoryRuntimeSettings,
type PluginManagementUpdate, type PluginManagementUpdate,
type PrivateSquareUpdate, type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
@@ -50,7 +47,6 @@ const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const; const LEADERBOARD_KEY = ["systemSettings", "leaderboard"] as const;
const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const; const SKILL_COMPRESSION_KEY = ["systemSettings", "skillCompression"] as const;
const MODEL_CONCURRENCY_KEY = ["systemSettings", "modelConcurrency"] 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 PLUGIN_MANAGEMENT_KEY = ["systemSettings", "pluginManagement"] as const;
const STRATEGY_PROJECTS_KEY = ["systemSettings", "strategyProjects"] 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 configured publish squares (发布广场). Readable by any logged-in user —
* the gallery sub-tabs and the publish-flow square picker both need it. */ * the gallery sub-tabs and the publish-flow square picker both need it. */
export function usePublishSquares() { export function usePublishSquares() {
+4 -9
View File
@@ -886,15 +886,8 @@ export function useThreadStream({
// Move to a fresh chat without a full browser reload; the old reload path // Move to a fresh chat without a full browser reload; the old reload path
// made every click look like a crash and could loop indefinitely when a // made every click look like a crash and could loop indefinitely when a
// different tab's cookie kept overriding this tab's Bearer token. // different tab's cookie kept overriding this tab's Bearer token.
if (isThreadNotAccessibleError(message)) { if (isThreadNotAccessibleError(message)) {
const projectHash = typeof window !== "undefined" ? window.location.hash : ""; const userLabel = buildCurrentUserLabel();
const projectMatch = projectHash.match(/#\/page\/workspace\/projects\/([^/]+)\//);
if (projectMatch) {
toast.info("当前项目对话不存在或无权访问,已返回项目空间。");
navigate("/page/workspace/projects", { replace: true });
return;
}
const userLabel = buildCurrentUserLabel();
toast.info( toast.info(
`当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`,
); );
@@ -1598,6 +1591,8 @@ export function useThreadStream({
: mergedContext.mode === "thinking" : mergedContext.mode === "thinking"
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled:
mergedContext.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled, rag_mode_enabled: ragModeEnabled,
summarization_enabled: contextCompressionEnabled, summarization_enabled: contextCompressionEnabled,
thread_id: threadId, thread_id: threadId,
+1
View File
@@ -19,6 +19,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
+6 -24
View File
@@ -45,17 +45,10 @@ export function pathOfThread(
base = "/page/workspace", base = "/page/workspace",
) { ) {
const threadId = typeof thread === "string" ? thread : thread.thread_id; 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 = const agentRef =
typeof thread === "string" typeof thread === "string"
? pickAgentRef(context, null) ? pickAgentRef(context, null)
: (agentRefOfThread(thread) ?? pickAgentRef(context, null)); : agentRefOfThread(thread) ?? pickAgentRef(context, null);
return agentRef return agentRef
? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
@@ -127,9 +120,7 @@ export function isSetupWizardThread(thread: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}): boolean { }): boolean {
const threadType = thread.metadata?.thread_type; const threadType = thread.metadata?.thread_type;
return ( return threadType === "writing_setup" || threadType === "report_structure_setup";
threadType === "writing_setup" || threadType === "report_structure_setup"
);
} }
/** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */ /** 侧栏 / 全部对话应隐藏的系统 / 向导会话(bootstrap 除外,仍会展示)。 */
@@ -139,10 +130,7 @@ export function isHiddenFromRecentChats(thread: {
const metadata = thread.metadata; const metadata = thread.metadata;
if (metadata?.system === true) return true; if (metadata?.system === true) return true;
const threadType = metadata?.thread_type; const threadType = metadata?.thread_type;
return ( return typeof threadType === "string" && HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType);
typeof threadType === "string" &&
HIDDEN_FROM_RECENT_CHAT_TYPES.has(threadType)
);
} }
/** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */ /** 调用方显式按 thread_type / system 过滤时,不要再套一层「最近对话」隐藏。 */
@@ -170,15 +158,11 @@ export function listPathOfThread(thread: AgentThread): string {
} }
export function isThreadPinned(thread: AgentThread): boolean { export function isThreadPinned(thread: AgentThread): boolean {
return ( return (thread.metadata as Record<string, unknown> | null)?.is_pinned === true;
(thread.metadata as Record<string, unknown> | null)?.is_pinned === true
);
} }
export function isThreadFavorite(thread: AgentThread): boolean { export function isThreadFavorite(thread: AgentThread): boolean {
return ( return (thread.metadata as Record<string, unknown> | null)?.is_favorite === true;
(thread.metadata as Record<string, unknown> | null)?.is_favorite === true
);
} }
/** /**
@@ -195,9 +179,7 @@ export function sortThreadsPinnedFirst(threads: AgentThread[]): AgentThread[] {
return [...pinned, ...rest]; return [...pinned, ...rest];
} }
export function sanitizeThreadTitleForDisplay( export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
title: string | null | undefined,
) {
if (!title) return "Untitled"; if (!title) return "Untitled";
const cleaned = title const cleaned = title
@@ -116,14 +116,6 @@ export interface EightBfTaskProcessReportReceipt {
id: string; id: string;
} }
/**
* 外部任务系统要求 8BF 会商页提交的过程产物携带 eightTask=1,
* 用于与岗位会商等其它入口的产物区分来源。
*/
function processReportBody(input: EightBfTaskProcessReportInput): string {
return JSON.stringify({ ...input, eightTask: 1 });
}
/** /**
* 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用
* `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。
@@ -165,10 +157,7 @@ async function externalTaskError(response: Response, fallback: string): Promise<
} }
function findProcessReportId(payload: unknown): string { function findProcessReportId(payload: unknown): string {
if (payload == null) return ""; if (!payload || typeof payload !== "object") return "";
// 外部任务系统的实际返回形如 {code:200, data:307, msg:"success"}:
// `data` 直接就是新增记录的 id(数字 / 字符串),不再包一层对象。
if (typeof payload !== "object") return firstText(payload);
const source = payload as Record<string, unknown>; const source = payload as Record<string, unknown>;
const direct = firstText( const direct = firstText(
source.id, source.id,
@@ -181,10 +170,9 @@ function findProcessReportId(payload: unknown): string {
} }
/** /**
* Create the external task system's process report **and submit it in one * Create the external task system's process report in its unsubmitted state.
* call**(submitStatus=1). Response shape is `{code:200, data:<id>, msg}` — * The local gateway stores the returned id so a refresh can later submit the
* `data` is the new record id. The local gateway stores that id so a refresh * same report instead of creating a duplicate one.
* can still reconcile the same report instead of creating a duplicate.
*/ */
export async function createEightBfTaskProcessReport( export async function createEightBfTaskProcessReport(
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -192,7 +180,7 @@ export async function createEightBfTaskProcessReport(
const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, { const response = await fetchExternalTaskSystem(PROCESS_REPORT_ADD_PATH, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: processReportBody(input), body: JSON.stringify(input),
}); });
if (!response.ok) throw await externalTaskError(response, "过程产物登记失败"); if (!response.ok) throw await externalTaskError(response, "过程产物登记失败");
const id = findProcessReportId(await response.json().catch(() => null)); const id = findProcessReportId(await response.json().catch(() => null));
@@ -200,7 +188,7 @@ export async function createEightBfTaskProcessReport(
return { id }; 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( export async function updateEightBfTaskProcessReport(
reportId: string, reportId: string,
input: EightBfTaskProcessReportInput, input: EightBfTaskProcessReportInput,
@@ -210,7 +198,7 @@ export async function updateEightBfTaskProcessReport(
{ {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: processReportBody(input), body: JSON.stringify(input),
}, },
); );
if (!response.ok) throw await externalTaskError(response, "过程产物提交失败"); if (!response.ok) throw await externalTaskError(response, "过程产物提交失败");
@@ -32,7 +32,6 @@ import {
} from "@/position-roundtable/lib/positions"; } from "@/position-roundtable/lib/positions";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
import { EIGHTBF_INTENT_AGENT_ID } from "@/roundtable-planning/api/intent"; 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 type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import "@/position-roundtable/styles/intent-understanding.css"; import "@/position-roundtable/styles/intent-understanding.css";
import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel";
@@ -47,8 +46,6 @@ export interface EightBfStep1PanelProps extends Step1PanelProps {
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
/** 业务链被本面板更新(产物 position 等)后回调,页面据此刷新链配置。 */
onChainUpdated?: (chain: BusinessChain) => void;
selectedModel: string; selectedModel: string;
modelOptions: Array<{ label: string; value: string }>; modelOptions: Array<{ label: string; value: string }>;
onSelectedModelChange: (model: string) => void; onSelectedModelChange: (model: string) => void;
@@ -87,7 +84,6 @@ export function EightBfStep1Panel({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
selectedModel, selectedModel,
modelOptions, modelOptions,
onSelectedModelChange, onSelectedModelChange,
@@ -157,7 +153,6 @@ export function EightBfStep1Panel({
chain={chain} chain={chain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={onConfigureChain} onConfigureChain={onConfigureChain}
onChainUpdated={onChainUpdated}
onCollapse={() => setRightPanelOpen(false)} onCollapse={() => setRightPanelOpen(false)}
step2Dialogues={step2Dialogues} step2Dialogues={step2Dialogues}
step2ThreadIds={step2ThreadIds} step2ThreadIds={step2ThreadIds}
@@ -304,7 +299,6 @@ function EightBfAgentWorkspace({
chain, chain,
canConfigureChain, canConfigureChain,
onConfigureChain, onConfigureChain,
onChainUpdated,
onCollapse, onCollapse,
step2Dialogues, step2Dialogues,
step2ThreadIds, step2ThreadIds,
@@ -316,7 +310,6 @@ function EightBfAgentWorkspace({
chain: BusinessChain | null; chain: BusinessChain | null;
canConfigureChain: boolean; canConfigureChain: boolean;
onConfigureChain: () => void; onConfigureChain: () => void;
onChainUpdated?: (chain: BusinessChain) => void;
onCollapse: () => void; onCollapse: () => void;
step2Dialogues: unknown[]; step2Dialogues: unknown[];
step2ThreadIds: ThreadIdMap | null; step2ThreadIds: ThreadIdMap | null;
@@ -455,13 +448,6 @@ function EightBfAgentWorkspace({
jobResponsibilities={selectedSeat?.job_responsibilities ?? ""} jobResponsibilities={selectedSeat?.job_responsibilities ?? ""}
skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />} skillsIcon={<img src={doubleArrowDecoration} alt="" aria-hidden="true" />}
skillsTitle="技能列表" skillsTitle="技能列表"
belowSkillsSlot={chain && selectedSeat ? (
<SeatArtifactPositionEditor
chain={chain}
seat={selectedSeat}
onUpdated={onChainUpdated}
/>
) : undefined}
showCommonQuestions showCommonQuestions
panelTitle="智能体配置" panelTitle="智能体配置"
embedded embedded
@@ -626,82 +612,3 @@ function arrayValue(...values: unknown[]): string[] {
} }
return []; 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,14 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
() => buildEightBfTaskContext(task, taskDictionaries), () => buildEightBfTaskContext(task, taskDictionaries),
[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;
};
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);
}
}
}
return map;
}, [chains, configuredChain]);
const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>( const taskArtifactSubmissionConfig = useMemo<TaskArtifactSubmissionConfig | undefined>(
() => taskId ? { () => taskId ? {
taskId, taskId,
position: 0, position: 0,
positionFor: (target) => {
const agentId = target.agentId?.trim();
if (!agentId) return null;
return seatArtifactPositions[agentId.toLowerCase()]
?? seatArtifactPositions[target.agentName?.trim().toLowerCase() ?? ""]
?? null;
},
createProcessReport: createEightBfTaskProcessReport, createProcessReport: createEightBfTaskProcessReport,
updateProcessReport: updateEightBfTaskProcessReport, updateProcessReport: updateEightBfTaskProcessReport,
} : undefined, } : undefined,
[taskId, seatArtifactPositions], [taskId],
); );
useEffect(() => { useEffect(() => {
@@ -235,12 +199,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
if (canConfigureChain) setIsSettingsOpen(true); if (canConfigureChain) setIsSettingsOpen(true);
}, [canConfigureChain]); }, [canConfigureChain]);
// 「智能体配置」面板里保存产物 position 后,把最新链数据写回页面状态,
// 让 seatArtifactPositions / 提交产物取值即时生效(无需刷新页面)。
const handleChainUpdated = useCallback((updated: BusinessChain) => {
setChains((prev) => prev.map((chain) => (chain.id === updated.id ? updated : chain)));
}, []);
const toolbarSlot = ( const toolbarSlot = (
<> <>
{canConfigureChain && isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
@@ -335,7 +293,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
chain={configuredChain} chain={configuredChain}
canConfigureChain={canConfigureChain} canConfigureChain={canConfigureChain}
onConfigureChain={openChainSettings} onConfigureChain={openChainSettings}
onChainUpdated={handleChainUpdated}
/> />
)} )}
renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => ( renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => (
@@ -386,8 +343,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} }
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
taskArtifactSubmissionConfig={taskArtifactSubmissionConfig} taskArtifactSubmissionConfig={taskArtifactSubmissionConfig}
// 会商「智能体配置」抽屉里只读展示各席位在业务链上配置的产物提交 position。
seatArtifactPositions={seatArtifactPositions}
/> />
</div> </div>
); );
@@ -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>
);
}
+18
View File
@@ -2,6 +2,7 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BrainCircuitIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
ShrinkIcon, ShrinkIcon,
@@ -399,6 +400,13 @@ export default function IframeChatPage() {
}); });
}, [setSettings, settings.context.writing_mode]); }, [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(() => { const toggleRagMode = useCallback(() => {
if (ragModeLocked) { if (ragModeLocked) {
return; return;
@@ -523,6 +531,9 @@ export default function IframeChatPage() {
const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({ const modeButtons: ResolvedIframeModeButton[] = buildIframeModeButtons({
agentSelected: !!selectedAgentId, agentSelected: !!selectedAgentId,
writingModeEnabled: !!settings.context.writing_mode, writingModeEnabled: !!settings.context.writing_mode,
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -544,6 +555,13 @@ export default function IframeChatPage() {
onClick: toggleWritingMode, onClick: toggleWritingMode,
activeClassName: "text-amber-600 hover:text-amber-700", 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": case "references":
return { return {
...button, ...button,
@@ -62,6 +62,7 @@ import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage"; import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
@@ -422,6 +423,7 @@ export default function StrategyRoutes() {
<Route path="skills" element={<SkillsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
<Route <Route
path="business-chain-mapping" path="business-chain-mapping"
element={ element={
+11 -12
View File
@@ -35,7 +35,6 @@ import { RouteBaseProvider } from "@/core/route-base";
import { agentRefOfThread } from "@/core/threads/utils"; import { agentRefOfThread } from "@/core/threads/utils";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import AnalysisProjectsPage from "./AnalysisProjectsPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
import ArtifactLibraryPage from "./ArtifactLibraryPage"; import ArtifactLibraryPage from "./ArtifactLibraryPage";
import IframeChatPage from "./IframeChatPage"; import IframeChatPage from "./IframeChatPage";
@@ -56,6 +55,7 @@ import WeKnoraPackageTransfersPage from "@/strategy-components/components/resour
import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage"; import MarkdownDocxEditorPage from "./MarkdownDocxEditorPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import CheckpointMigrationPage from "./CheckpointMigrationPage"; import CheckpointMigrationPage from "./CheckpointMigrationPage";
import MemoryPage from "./MemoryPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage"; import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage"; import ModelConcurrencySettingsPage from "./ModelConcurrencySettingsPage";
@@ -130,6 +130,7 @@ function shouldShowCompactTopActions(pathname: string): boolean {
return ( return (
pathname === "/page/workspace/agents" || pathname === "/page/workspace/agents" ||
pathname === "/page/workspace/skills" || pathname === "/page/workspace/skills" ||
pathname === "/page/workspace/memory" ||
pathname === "/page/workspace/library" || pathname === "/page/workspace/library" ||
pathname === "/page/workspace/knowledge" || pathname === "/page/workspace/knowledge" ||
pathname.startsWith("/page/workspace/knowledge/") pathname.startsWith("/page/workspace/knowledge/")
@@ -190,9 +191,7 @@ export function WorkspaceLayout({
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页 // 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。 // (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const pathname = useLocation().pathname; const pathname = useLocation().pathname;
const isGeneralQa = const isGeneralQa = pathname.startsWith("/page/workspace/chats");
pathname.startsWith("/page/workspace/chats") ||
pathname.startsWith("/page/workspace/projects");
const showCompactTopActions = const showCompactTopActions =
headerHidden && headerHidden &&
!compactActionsHidden && !compactActionsHidden &&
@@ -549,14 +548,6 @@ export default function WorkspaceRoutes({
<RouteBaseProvider value="/page/workspace"> <RouteBaseProvider value="/page/workspace">
<Routes> <Routes>
<Route index element={<Navigate to="chats/new" replace />} /> <Route index element={<Navigate to="chats/new" replace />} />
<Route
path="projects/*"
element={
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen insetOverflowHidden>
<AnalysisProjectsPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="chats" path="chats"
element={ element={
@@ -693,6 +684,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="memory"
element={
<WorkspaceLayout {...layoutProps}>
<MemoryPage />
</WorkspaceLayout>
}
/>
<Route <Route
path="ai-writing/*" path="ai-writing/*"
element={ 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, appliedContent,
/** 点击卡片选择结构时回填内容与结构约束。 */ /** 点击卡片选择结构时回填内容与结构约束。 */
onApply, onApply,
inlineCreate = false,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
appliedContent?: string; appliedContent?: string;
inlineCreate?: boolean;
onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply?: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
}) { }) {
const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ReportStructureStatusFilter>("all");
@@ -402,11 +400,6 @@ export function WorkflowConfigDialog({
<Button <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" 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={() => { onClick={() => {
if (inlineCreate) {
setEditorTarget(null);
setEditorOpen(true);
return;
}
onOpenChange(false); onOpenChange(false);
const base = location.pathname.replace(/\/+$/, ""); const base = location.pathname.replace(/\/+$/, "");
navigate(`${base}/structures/new`); navigate(`${base}/structures/new`);
@@ -1199,7 +1192,6 @@ export function WorkflowConfigButton({
variant = "outline", variant = "outline",
size = "sm", size = "sm",
disabled = false, disabled = false,
inlineCreate = false,
}: { }: {
appliedContent?: string; appliedContent?: string;
onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void; onApply: (content: string, structureMode: ReportStructureMode, retrievalDirections: string[], retrievalSkills: string[]) => void;
@@ -1207,7 +1199,6 @@ export function WorkflowConfigButton({
variant?: React.ComponentProps<typeof Button>["variant"]; variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"]; size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean; disabled?: boolean;
inlineCreate?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation(); const location = useLocation();
@@ -1231,7 +1222,6 @@ export function WorkflowConfigButton({
return ( return (
<> <>
<Button <Button
type="button"
aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"} aria-label={selectedWorkflowTitle ? `已选择工作流:${selectedWorkflowTitle}` : "工作流配置"}
className={cn( className={cn(
className, className,
@@ -1247,7 +1237,6 @@ export function WorkflowConfigButton({
<span className="max-w-40 truncate">{buttonLabel}</span> <span className="max-w-40 truncate">{buttonLabel}</span>
</Button> </Button>
<WorkflowConfigDialog <WorkflowConfigDialog
inlineCreate={inlineCreate}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
appliedContent={appliedContent} appliedContent={appliedContent}
@@ -4,10 +4,11 @@ import test from "node:test";
// @ts-ignore Node's test runner resolves the local .ts module directly. // @ts-ignore Node's test runner resolves the local .ts module directly.
import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts"; 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({ const buttons = buildIframeModeButtons({
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true, referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: 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", () => { test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false, referenceModeEntryEnabled: false,
ragModeEnabled: true, ragModeEnabled: true,
ragModeLocked: false, ragModeLocked: false,
@@ -45,6 +63,7 @@ test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: true, agentSelected: true,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true, referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
@@ -2,6 +2,7 @@ export type IframeModeButtonState = {
key: key:
| "agent" | "agent"
| "writing" | "writing"
| "memory"
| "references" | "references"
| "contextCompression" | "contextCompression"
| "pro"; | "pro";
@@ -13,6 +14,9 @@ export type IframeModeButtonState = {
type BuildIframeModeButtonsInput = { type BuildIframeModeButtonsInput = {
agentSelected: boolean; agentSelected: boolean;
writingModeEnabled: boolean; writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
/** 产品当前全局隐藏记忆入口;保留 false 供后续无损恢复。 */
hideMemoryButton?: boolean;
referenceModeEntryEnabled: boolean; referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean; ragModeEnabled: boolean;
ragModeLocked: boolean; ragModeLocked: boolean;
@@ -23,6 +27,8 @@ type BuildIframeModeButtonsInput = {
export function buildIframeModeButtons({ export function buildIframeModeButtons({
agentSelected, agentSelected,
writingModeEnabled, writingModeEnabled,
memoryInjectionEnabled,
hideMemoryButton = true,
referenceModeEntryEnabled, referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
@@ -35,6 +41,11 @@ export function buildIframeModeButtons({
label: "写作", label: "写作",
active: writingModeEnabled, active: writingModeEnabled,
}, },
{
key: "memory",
label: "记忆",
active: memoryInjectionEnabled,
},
{ {
key: "references", key: "references",
label: "参考文献", label: "参考文献",
@@ -58,6 +69,8 @@ export function buildIframeModeButtons({
}, },
]; ];
return buttons.filter( 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"> <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")}> <h2 className={cn("truncate text-base font-semibold text-slate-900 dark:text-white", embedded && "text-lg font-[550] dark:text-white")}>业务链条</h2>
{chain?.title?.trim() || "业务链条"} {!embedded && (
</h2>
{!embedded && !chain && (
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite"> <p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{loading ? "正在加载管理员配置…" : "等待管理员配置"} {chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p> </p>
)} )}
</div> </div>
@@ -34,8 +34,6 @@ export interface PersonaDrawerProps {
selectedModel: string; selectedModel: string;
availableModels: ModelOption[]; availableModels: ModelOption[];
modelsLoading: boolean; modelsLoading: boolean;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示该行。 */
seatArtifactPositions?: Record<string, number>;
onClose: () => void; onClose: () => void;
onSave: (id: number) => void; onSave: (id: number) => void;
} }
@@ -68,16 +66,11 @@ export function PersonaDrawer({
selectedModel, selectedModel,
availableModels, availableModels,
modelsLoading, modelsLoading,
seatArtifactPositions,
onClose, onClose,
onSave, onSave,
}: PersonaDrawerProps) { }: PersonaDrawerProps) {
const cfg = agentConfigs[seatId]; const cfg = agentConfigs[seatId];
const roleEntry = roles.find((r) => r.id === seatId); const roleName = roles.find((r) => r.id === seatId)?.name ?? "专家智能体";
const roleName = roleEntry?.name ?? "专家智能体";
// 该席位在业务链上配置的「产物提交 position」——提交过程产物时传给外部任务系统。
const lookupKey = roleEntry?.agentId?.trim().toLowerCase() || roleName.trim().toLowerCase();
const artifactPosition = lookupKey ? seatArtifactPositions?.[lookupKey] : undefined;
return ( 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"> <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> </select>
</div> </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="space-y-1">
<div className="flex items-center justify-between text-[9px] font-black text-slate-500"> <div className="flex items-center justify-between text-[9px] font-black text-slate-500">
<span>随机创造性倾向 (Temp)</span> <span>随机创造性倾向 (Temp)</span>
@@ -115,8 +115,6 @@ export interface Step2PanelProps {
agentConfigs: Record<number, AgentConfig>; agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null; selectedAgentId: number | null;
roles: RoleEntry[]; roles: RoleEntry[];
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值);缺省不展示。 */
seatArtifactPositions?: Record<string, number>;
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)────────────── // ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */ /** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null; seatFilter: SeatFilter | null;
@@ -189,7 +187,6 @@ export function Step2Panel(props: Step2PanelProps) {
agentConfigs, agentConfigs,
selectedAgentId, selectedAgentId,
roles, roles,
seatArtifactPositions,
seatFilter, seatFilter,
setSeatFilter, setSeatFilter,
parallelBatch, parallelBatch,
@@ -583,7 +580,6 @@ export function Step2Panel(props: Step2PanelProps) {
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading} modelsLoading={modelsLoading}
seatArtifactPositions={seatArtifactPositions}
onClose={() => setSelectedAgentId(null)} onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona} onSave={onSavePersona}
/> />
@@ -55,14 +55,12 @@ function payloadFrom(
): TaskProcessReportPayload { ): TaskProcessReportPayload {
const name = target.name?.trim() || fileName(target.path); const name = target.name?.trim() || fileName(target.path);
const agentId = target.agentId?.trim() || target.threadId; const agentId = target.agentId?.trim() || target.threadId;
// 产物提交 position:优先取业务链里给该席位智能体配置的值,未配置回退默认。
const position = config.positionFor?.(target) ?? config.position;
return { return {
taskId: config.taskId, taskId: config.taskId,
agentId, agentId,
agentName: target.agentName?.trim() || agentId, agentName: target.agentName?.trim() || agentId,
content, content,
position, position: config.position,
reportName: name, reportName: name,
submitStatus, submitStatus,
}; };
@@ -148,11 +146,8 @@ export function useTaskArtifactSubmission(
let record = entriesRef.current[key]?.record; let record = entriesRef.current[key]?.record;
let reportId = record?.taskDeliveryId || undefined; let reportId = record?.taskDeliveryId || undefined;
const resumedRegistration = Boolean(reportId);
if (!reportId) { if (!reportId) {
// 外部任务系统(8BF)的实际契约:add 接口以 submitStatus=1 直接完成提交, const receipt = await config.createProcessReport(payloadFrom(config, target, content, 0));
// 返回 {code:200, data:id}。登记即提交,不再走「先落 0 再改 1」两段式。
const receipt = await config.createProcessReport(payloadFrom(config, target, content, 1));
reportId = receipt.id; reportId = receipt.id;
const artifact: RoundtableArtifactReference = { const artifact: RoundtableArtifactReference = {
threadId: target.threadId, threadId: target.threadId,
@@ -168,11 +163,9 @@ export function useTaskArtifactSubmission(
taskDeliveryId: reportId, taskDeliveryId: reportId,
artifact, 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, { const submitted = await saveRoundtableArtifactSubmission(config.taskId, {
submitted: true, submitted: true,
taskDeliveryId: reportId, taskDeliveryId: reportId,
@@ -55,13 +55,6 @@ export interface ChainSeat {
* 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。 * 使同一条业务链既能继续用于原会商,也能在岗位协同中按岗位筛选可问答的智能体。
*/ */
position_id?: string | null; position_id?: string | null;
/**
* 该席位提交过程产物时传给外部任务系统的 `position` 值(8BF 会商页用)。
* 不同席位智能体对应不同的产物位置,缺省 / null = 提交时回退 0。
* 随 `seats` JSON 落库(无需 DB 列,但需在后端 `ChainSeatSchema` 显式声明)。
* 注意与 `position_id`(岗位协同的岗位归属)无关。
*/
artifact_position?: number | null;
/** /**
* 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示; * 该席位在业务链中配置的岗位职责。会商「智能体配置」面板只读展示;
* 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。 * 随 `seats` JSON 落库(无需 DB 列)。空 / 缺省 = 未填写。
@@ -13,14 +13,7 @@ export interface TaskProcessReportPayload {
export interface TaskArtifactSubmissionConfig { export interface TaskArtifactSubmissionConfig {
/** External task-system id, not the local roundtable draft storage key. */ /** External task-system id, not the local roundtable draft storage key. */
taskId: string; taskId: string;
/** 提交产物时传给外部任务系统的默认 position(产物位置)。 */
position: number; position: number;
/**
* 按席位智能体解析提交产物的 position(业务链里逐席位配置的「产物提交 position」)。
* 入参是 hook 解析后的 target(`agentId` 已归一为席位 agent_id);返回
* null / undefined = 该席位未配置,回退 `position` 默认值。
*/
positionFor?: (target: TaskArtifactTarget) => number | null | undefined;
createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>; createProcessReport: (payload: TaskProcessReportPayload) => Promise<{ id: string }>;
updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>; updateProcessReport: (reportId: string, payload: TaskProcessReportPayload) => Promise<void>;
} }
@@ -697,8 +697,6 @@ export default function BusinessChainEditorPage() {
seat_mode: s.seat_mode ?? null, seat_mode: s.seat_mode ?? null,
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。 // 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
position_id: s.position_id ?? null, position_id: s.position_id ?? null,
// 席位「产物提交 position」:提交过程产物时传给外部任务系统;null = 回退默认 0。
artifact_position: s.artifact_position ?? null,
// 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。 // 业务链席位岗位职责:编辑器已不提供输入,保存时原样保留存量值(会商智能体配置面板只读展示)。
job_responsibilities: s.job_responsibilities?.trim() || null, job_responsibilities: s.job_responsibilities?.trim() || null,
}; };
@@ -361,11 +361,6 @@ export default function RoundtablePlanningPage({
hideHeaderSettings = false, hideHeaderSettings = false,
/** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */ /** 可选的外部任务过程产物提交适配器(8BF 使用,通用圆桌保持不显示)。 */
taskArtifactSubmissionConfig, taskArtifactSubmissionConfig,
/**
* 席位 → 业务链配置的「产物提交 position」映射(键为小写 agent_id / 席位名)。
* 仅用于「智能体配置」抽屉里的只读展示,帮助核对提交产物时会带的 position 值。
*/
seatArtifactPositions,
/** /**
* 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏; * 第三步的交付范围。普通会商保留报告后的流程图、最终方案/席位列表与可视化大屏;
* 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。 * 8BF 只交付 Markdown 总结报告,避免两个入口互相改变行为。
@@ -422,8 +417,6 @@ export default function RoundtablePlanningPage({
newTaskLabel?: string; newTaskLabel?: string;
hideHeaderSettings?: boolean; hideHeaderSettings?: boolean;
taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig; taskArtifactSubmissionConfig?: TaskArtifactSubmissionConfig;
/** 席位 → 业务链配置的「产物提交 position」(小写 agent_id / 席位名 → 值),只读展示用。 */
seatArtifactPositions?: Record<string, number>;
step3Mode?: "full" | "report-only"; step3Mode?: "full" | "report-only";
}) { }) {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -2621,7 +2614,6 @@ export default function RoundtablePlanningPage({
orchestrationPlan={orchestrationPlan} orchestrationPlan={orchestrationPlan}
dagNodeStates={step2.dagNodeStates} dagNodeStates={step2.dagNodeStates}
agentNameToRole={step2.agentNameToRole} agentNameToRole={step2.agentNameToRole}
seatArtifactPositions={seatArtifactPositions}
selectedModel={selectedModel} selectedModel={selectedModel}
availableModels={availableModels} availableModels={availableModels}
modelsLoading={modelsLoading} modelsLoading={modelsLoading}
@@ -73,21 +73,6 @@ export interface LlmWikiKnowledgeBase {
is_conversation_deposit?: boolean; is_conversation_deposit?: boolean;
} }
export interface LlmWikiEmbeddingModel {
id: string;
name: string;
provider?: string | null;
model_name?: string | null;
status?: string | null;
is_default: boolean;
available: boolean;
}
export interface LlmWikiEmbeddingModelsResponse {
models: LlmWikiEmbeddingModel[];
default_model_id?: string | null;
}
export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase { export interface PublicLlmWikiKnowledgeBase extends LlmWikiKnowledgeBase {
wiki: { wiki: {
status: "ready" | "empty" | "unavailable"; status: "ready" | "empty" | "unavailable";
@@ -283,9 +268,6 @@ export interface LlmWikiWikiIndexStatus {
export interface LocalWikiEncoderStatus { export interface LocalWikiEncoderStatus {
provider: string; provider: string;
configured_provider?: string | null;
mode?: "configured" | "local";
using_local?: boolean;
model: string; model: string;
dimensions?: number | null; dimensions?: number | null;
state: "idle" | "starting" | "ready" | "failed"; state: "idle" | "starting" | "ready" | "failed";
@@ -442,17 +424,6 @@ export async function getLlmWikiKnowledgeBase(
); );
} }
export async function listLlmWikiEmbeddingModels(): Promise<LlmWikiEmbeddingModelsResponse> {
const payload = await readJson<LlmWikiEmbeddingModelsResponse>(
await apiFetch(`${API()}/embedding-models`),
"读取知识服务向量模型",
);
return {
models: payload.models ?? [],
default_model_id: payload.default_model_id ?? null,
};
}
export async function createLlmWikiWeKnoraEmbedSession( export async function createLlmWikiWeKnoraEmbedSession(
id: string, id: string,
tab: "documents" | "wiki" | "graph", tab: "documents" | "wiki" | "graph",
@@ -486,7 +457,6 @@ export async function createLlmWikiKnowledgeBase(input: {
description: string; description: string;
type: "document" | "faq"; type: "document" | "faq";
wiki_enabled?: boolean; wiki_enabled?: boolean;
embedding_model_id?: string;
}): Promise<LlmWikiKnowledgeBase> { }): Promise<LlmWikiKnowledgeBase> {
return readJson( return readJson(
await apiFetch(`${API()}/knowledge-bases`, { await apiFetch(`${API()}/knowledge-bases`, {
@@ -684,13 +654,6 @@ export async function configureLocalWikiEncoder(
); );
} }
export async function restoreConfiguredWikiEncoder(): Promise<LocalWikiEncoderStatus> {
return readJson(
await apiFetch(`${API()}/local-encoder/use-configured`, { method: "POST" }),
"恢复配置文件编码器",
);
}
export async function uploadLocalWikiEncoder( export async function uploadLocalWikiEncoder(
files: File[], files: File[],
onProgress?: (percent: number) => void, onProgress?: (percent: number) => void,
@@ -725,15 +688,10 @@ export async function uploadLocalWikiEncoder(
payload = null; payload = null;
} }
if (request.status < 200 || request.status >= 300) { if (request.status < 200 || request.status >= 300) {
const rawDetail = payload && typeof payload === "object" && "detail" in payload const detail = payload && typeof payload === "object" && "detail" in payload
? (payload as { detail: unknown }).detail ? (payload as { detail: unknown }).detail
: `请求失败 (${request.status})`; : `请求失败 (${request.status})`;
const detail = rawDetail && typeof rawDetail === "object" && "message" in rawDetail reject(new Error(typeof detail === "string" ? detail : JSON.stringify(detail)));
? String((rawDetail as { message: unknown }).message)
: typeof rawDetail === "string"
? rawDetail
: JSON.stringify(rawDetail);
reject(new Error(userFacingKnowledgeMessage(detail)));
return; return;
} }
onProgress?.(100); onProgress?.(100);
@@ -1310,17 +1268,6 @@ export function useLlmWikiSettings(enabled = true) {
}); });
} }
export function useLlmWikiEmbeddingModels(enabled = true) {
const { userId } = useLiveAccount();
return useQuery({
queryKey: ["llmwiki", "embedding-models", userId ?? "anonymous"],
queryFn: listLlmWikiEmbeddingModels,
enabled,
staleTime: 60_000,
refetchOnMount: "always",
});
}
export function useLlmWikiActions() { export function useLlmWikiActions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidateKnowledge = () => const invalidateKnowledge = () =>
@@ -34,13 +34,6 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -55,9 +48,7 @@ import {
searchLlmWiki, searchLlmWiki,
startLocalWikiEncoder, startLocalWikiEncoder,
uploadLocalWikiEncoder, uploadLocalWikiEncoder,
restoreConfiguredWikiEncoder,
useLlmWikiActions, useLlmWikiActions,
useLlmWikiEmbeddingModels,
useLlmWikiKnowledgeBases, useLlmWikiKnowledgeBases,
useLlmWikiSettings, useLlmWikiSettings,
useLlmWikiSettingsActions, useLlmWikiSettingsActions,
@@ -77,14 +68,6 @@ function messageOf(error: unknown): string {
return error instanceof Error ? error.message : "操作失败,请稍后重试"; return error instanceof Error ? error.message : "操作失败,请稍后重试";
} }
function encoderProviderLabel(provider?: string | null): string {
const normalized = provider?.trim().toLowerCase();
if (normalized === "local") return "本地 BGE-M3";
if (normalized === "openai") return "OpenAI";
if (normalized === "openai_compatible") return "OpenAI 兼容服务";
return provider?.trim() || "未配置";
}
function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) { function searchModeLabel(mode: LlmWikiSearchResponse["mode"]) {
if (mode === "vector") return "本地 Wiki 向量检索"; if (mode === "vector") return "本地 Wiki 向量检索";
if (mode === "mixed") return "Wiki 混合检索"; if (mode === "mixed") return "Wiki 混合检索";
@@ -474,50 +457,18 @@ function CreateKnowledgeDialog({
open: boolean; open: boolean;
pending: boolean; pending: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean; embedding_model_id: string }) => Promise<void>; onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [wikiEnabled, setWikiEnabled] = useState(true); const [wikiEnabled, setWikiEnabled] = useState(true);
const [embeddingModelId, setEmbeddingModelId] = useState("");
const embeddingModels = useLlmWikiEmbeddingModels(open);
const availableEmbeddingModels = useMemo(
() => (embeddingModels.data?.models ?? []).filter((model) => model.available !== false),
[embeddingModels.data?.models],
);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
setName("");
setDescription("");
setWikiEnabled(true);
setEmbeddingModelId("");
}
}, [open]); }, [open]);
useEffect(() => {
if (!open || !embeddingModels.data || availableEmbeddingModels.length === 0) return;
if (availableEmbeddingModels.some((model) => model.id === embeddingModelId)) return;
const defaultId = embeddingModels.data.default_model_id;
setEmbeddingModelId(
availableEmbeddingModels.some((model) => model.id === defaultId)
? defaultId!
: availableEmbeddingModels[0]!.id,
);
}, [availableEmbeddingModels, embeddingModelId, embeddingModels.data, open]);
const modelLabel = (model: (typeof availableEmbeddingModels)[number]) => {
const provider = model.provider?.trim();
const implementation = model.model_name?.trim();
const suffix = [provider, implementation].filter(Boolean).join(" · ");
return suffix ? `${model.name}(${suffix})` : model.name;
};
const modelSelectionUnavailable = embeddingModels.isLoading || embeddingModels.isError || !embeddingModelId;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式和知识服务用于原文分块的向量模型。</DialogDescription></DialogHeader> <DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由知识服务管理。</DialogDescription></DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div> <div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div> <div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
@@ -525,33 +476,8 @@ function CreateKnowledgeDialog({
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button> <button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button> <button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
</div></div> </div></div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="llmwiki-embedding-model">原文向量模型</label>
{embeddingModels.isLoading ? (
<div className="flex h-9 items-center rounded-md border px-3 text-sm text-muted-foreground"><Loader2 className="mr-2 size-4 animate-spin" />正在读取知识服务模型</div>
) : embeddingModels.isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>模型读取失败:{messageOf(embeddingModels.error)}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void embeddingModels.refetch()}>重新读取</Button>
</div>
) : availableEmbeddingModels.length ? (
<Select value={embeddingModelId} onValueChange={setEmbeddingModelId} disabled={pending}>
<SelectTrigger id="llmwiki-embedding-model" className="w-full"><SelectValue placeholder="请选择向量模型" /></SelectTrigger>
<SelectContent position="popper">
{availableEmbeddingModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{modelLabel(model)}{model.is_default ? " · 默认" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">知识服务中没有可用的 Embedding 模型,请先在知识服务中启用本地或 OpenAI 兼容向量模型。</div>
)}
<p className="text-xs leading-5 text-muted-foreground">该模型由知识服务(WeKnora)对上传原文和分块进行向量化,并在创建时绑定到新库。它与 DeerFlow 的“Wiki 镜像编码器”是两套独立配置;选择 OpenAI 兼容模型时,无需再配置本地 BGE-M3。需要跨知识库检索时,请为相关知识库保持相同或兼容的向量模型。</p>
</div>
</div> </div>
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim() || modelSelectionUnavailable} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled, embedding_model_id: embeddingModelId })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter> <DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -575,12 +501,11 @@ export function LocalEncoderSettingsCard() {
const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => { const applyStatus = (status: Awaited<ReturnType<typeof getLocalWikiEncoderStatus>>) => {
queryClient.setQueryData(["llmwiki-local-encoder"], status); queryClient.setQueryData(["llmwiki-local-encoder"], status);
void queryClient.invalidateQueries({ queryKey: ["llmwiki", "wiki-index-status"] });
setModelPath(status.model_path || ""); setModelPath(status.model_path || "");
}; };
const configure = useMutation({ const configure = useMutation({
mutationFn: () => configureLocalWikiEncoder(modelPath.trim()), mutationFn: () => configureLocalWikiEncoder(modelPath.trim()),
onSuccess: (status) => { applyStatus(status); toast.success("已切换到本地 Wiki 编码器并保存目录"); }, onSuccess: (status) => { applyStatus(status); toast.success("编码器目录已保存"); },
onError: (error) => toast.error(messageOf(error)), onError: (error) => toast.error(messageOf(error)),
}); });
const upload = useMutation({ const upload = useMutation({
@@ -589,7 +514,7 @@ export function LocalEncoderSettingsCard() {
onSuccess: (status) => { onSuccess: (status) => {
applyStatus(status); applyStatus(status);
setFiles([]); setFiles([]);
toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件,并切换到本地 Wiki 编码器`); toast.success(`已保存 ${status.uploaded_files ?? 0} 个模型文件`);
}, },
onError: (error) => toast.error(messageOf(error)), onError: (error) => toast.error(messageOf(error)),
}); });
@@ -601,17 +526,7 @@ export function LocalEncoderSettingsCard() {
}, },
onError: (error) => toast.error(messageOf(error)), onError: (error) => toast.error(messageOf(error)),
}); });
const restoreConfigured = useMutation({
mutationFn: restoreConfiguredWikiEncoder,
onSuccess: (nextStatus) => {
applyStatus(nextStatus);
toast.success(`已恢复 config.yaml 编码器:${encoderProviderLabel(nextStatus.provider)}`);
},
onError: (error) => toast.error(messageOf(error)),
});
const status = encoder.data; const status = encoder.data;
const usingLocal = status?.using_local ?? status?.provider === "local";
const localOverrideActive = status?.mode === "local";
const selectedBytes = files.reduce((sum, file) => sum + file.size, 0); const selectedBytes = files.reduce((sum, file) => sum + file.size, 0);
const formatSize = (value: number) => value >= 1024 ** 3 const formatSize = (value: number) => value >= 1024 ** 3
? `${(value / 1024 ** 3).toFixed(2)} GB` ? `${(value / 1024 ** 3).toFixed(2)} GB`
@@ -620,49 +535,29 @@ export function LocalEncoderSettingsCard() {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow Wiki 镜像编码器</CardTitle> <CardTitle className="flex items-center gap-2 text-base"><Cpu className="size-5" />DeerFlow 本地编码器</CardTitle>
<CardDescription>仅管理员可见。这里负责 DeerFlow 内部 Wiki 页面镜像的向量检索,不会决定知识服务对上传原文使用哪个模型。原文向量模型在“新建知识库”时选择。</CardDescription> <CardDescription>仅管理员可见。配置服务器上已有的 BGE-M3 目录,或直接上传完整离线模型目录;编码器作为独立进程启动,不影响 DeerFlow 其他服务。</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<Badge variant={encoder.isError || (usingLocal && status?.state === "failed") ? "destructive" : usingLocal && status?.state === "ready" ? "default" : "secondary"}> <Badge variant={status?.state === "failed" ? "destructive" : status?.state === "ready" ? "default" : "secondary"}>
{encoder.isError {status?.state === "ready" ? "已就绪" : status?.state === "starting" ? "启动中" : status?.state === "failed" ? "启动失败" : "未启动"}
? "状态读取失败"
: encoder.isLoading
? "读取中"
: !usingLocal
? "配置编码器生效中"
: status?.state === "ready"
? "本地编码器已就绪"
: status?.state === "starting"
? "本地编码器启动中"
: status?.state === "failed"
? "本地编码器启动失败"
: "本地编码器未启动"}
</Badge> </Badge>
{status && <Badge variant="outline">当前 provider:{encoderProviderLabel(status.provider)}</Badge>} <span className="text-muted-foreground">{status ? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}` : "正在读取状态"}</span>
{status && <Badge variant="outline">{localOverrideActive ? "页面本地覆盖" : "config.yaml 配置"}</Badge>} {status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
<span className="text-muted-foreground">
{status
? usingLocal
? `${status.model} · ${status.dimensions || "自动探测"} 维 · ${status.process_isolation ? "独立进程" : "主进程"}`
: `由 config.yaml 的 ${encoderProviderLabel(status.configured_provider || status.provider)} provider 提供向量服务,无需启动本地模型`
: "正在读取状态"}
</span>
{usingLocal && status?.model_files_ready && <Badge variant="outline">模型文件已就绪</Badge>}
</div> </div>
{encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>} {encoder.error && <p className="text-sm text-destructive">{messageOf(encoder.error)}</p>}
{usingLocal && status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>} {status?.last_error && <p className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{status.last_error}</p>}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="local-encoder-path">切换到服务器本地模型目录</label> <label className="text-sm font-medium" htmlFor="local-encoder-path">使用服务器已有目录</label>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" /> <Input id="local-encoder-path" value={modelPath} onChange={(event) => setModelPath(event.target.value)} placeholder="例如 /opt/deerflow/models/bge-m3" />
<Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}> <Button variant="outline" disabled={!modelPath.trim() || configure.isPending} onClick={() => configure.mutate()}>
{configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录 {configure.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存目录
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。保存后会立即把 Wiki 镜像编码器切换为 local 并热重建,无需修改配置文件或重启服务。</p> <p className="text-xs text-muted-foreground">目录内需包含 <code>onnx/model.onnx</code>(推荐)或根目录 <code>model.onnx</code>。</p>
</div> </div>
<div className="space-y-2 rounded-xl border border-dashed p-4"> <div className="space-y-2 rounded-xl border border-dashed p-4">
@@ -681,7 +576,7 @@ export function LocalEncoderSettingsCard() {
)} )}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。上传完成后会自动切换到 local;大文件会落到运行目录,不写入源码仓库。"} {files.length ? `已选择 ${files.length} 个文件,共 ${formatSize(selectedBytes)}` : "请选择完整 BGE-M3 目录。大文件会落到运行目录,不写入源码仓库。"}
</p> </p>
<Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}> <Button disabled={!files.length || upload.isPending} onClick={() => upload.mutate()}>
{upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />} {upload.isPending ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Upload className="mr-2 size-4" />}
@@ -691,16 +586,10 @@ export function LocalEncoderSettingsCard() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button disabled={!usingLocal || !status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}> <Button disabled={!status?.model_files_ready || start.isPending || status?.state === "starting" || status?.state === "ready"} onClick={() => start.mutate()}>
{(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />} {(start.isPending || status?.state === "starting") && <Loader2 className="mr-2 size-4 animate-spin" />}
{!usingLocal ? "当前无需启动本地模型" : status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"} {status?.state === "ready" ? "编码器已启动" : status?.state === "failed" ? "重新启动" : "一键启动编码器"}
</Button> </Button>
{localOverrideActive && (
<Button variant="secondary" disabled={restoreConfigured.isPending} onClick={() => restoreConfigured.mutate()}>
{restoreConfigured.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
恢复 config.yaml 编码器
</Button>
)}
<Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}> <Button variant="ghost" disabled={encoder.isFetching} onClick={() => void encoder.refetch()}>
<RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态 <RefreshCw className={`mr-2 size-4 ${encoder.isFetching ? "animate-spin" : ""}`} />刷新状态
</Button> </Button>
@@ -601,19 +601,13 @@ export function WeKnoraWikiManagementPage() {
} }
}; };
const vectorStatusText = indexStatus.isLoading const vectorStatusText = !indexStatus.data?.local_index_enabled
? "正在读取 Wiki 镜像向量状态" ? "本地向量未启用,检索使用 Wiki 接口"
: indexStatus.isError : indexStatus.data.fully_vectorized
? `Wiki 镜像向量状态读取失败:${indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}` ? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: !indexStatus.data?.local_index_enabled : indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "Wiki 镜像向量未启用,检索使用 Wiki 接口" ? "整库向量化进行中,完成前检索使用 Wiki 接口"
: indexStatus.data.fully_vectorized : "整库尚未完成向量化,检索使用 Wiki 接口";
? `整库已向量化(${indexStatus.data.vector_count ?? 0} 个向量章节)`
: indexStatus.data.state === "running" || indexStatus.data.state === "rebuilding"
? "整库向量化进行中,完成前检索使用 Wiki 接口"
: indexStatus.data.last_error
? `整库向量化失败:${indexStatus.data.last_error}`
: "整库尚未完成向量化,检索使用 Wiki 接口";
return ( return (
<div className="flex h-full min-h-0 flex-col bg-background"> <div className="flex h-full min-h-0 flex-col bg-background">
@@ -632,7 +626,7 @@ export function WeKnoraWikiManagementPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => void vectorize()} onClick={() => void vectorize()}
disabled={!canWrite || indexStatus.isLoading || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"} disabled={!canWrite || actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding"}
> >
{actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? ( {actions.vectorizeWiki.isPending || indexStatus.data?.state === "running" || indexStatus.data?.state === "rebuilding" ? (
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="mr-2 size-4 animate-spin" />
@@ -658,17 +652,6 @@ export function WeKnoraWikiManagementPage() {
<main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden"> <main className="grid min-h-0 flex-1 grid-cols-[320px_1fr] overflow-hidden">
<aside className="flex min-h-0 flex-col border-r bg-muted/20"> <aside className="flex min-h-0 flex-col border-r bg-muted/20">
<div className="border-b p-3"> <div className="border-b p-3">
{indexStatus.isLoading && (
<div className="mb-3 flex items-center rounded-lg border bg-background p-3 text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取 Wiki 镜像向量状态
</div>
)}
{indexStatus.isError && (
<div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
<p>Wiki 镜像向量状态读取失败:{indexStatus.error instanceof Error ? indexStatus.error.message : "请稍后重试"}</p>
<Button type="button" variant="ghost" size="sm" className="mt-1 h-7 px-2" onClick={() => void indexStatus.refetch()}>重新读取</Button>
</div>
)}
{indexStatus.data && ( {indexStatus.data && (
<div className="mb-3 rounded-lg border bg-background p-3"> <div className="mb-3 rounded-lg border bg-background p-3">
<div className="flex items-center justify-between gap-2 text-xs"> <div className="flex items-center justify-between gap-2 text-xs">
+3 -48
View File
@@ -4,19 +4,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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. 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**: **Architecture**:
@@ -90,7 +77,7 @@ When making code changes, you MUST update the relevant documentation:
### Workflow Studio stream and canvas compatibility ### 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. - 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/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}`. - `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}`.
@@ -1153,15 +1140,6 @@ Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` me
### Conditional WeKnora LLMWiki ### Conditional WeKnora LLMWiki
**Embedding ownership:** WeKnora's per-knowledge-base `embedding_model_id`
indexes uploaded source documents; `llmwiki.local_wiki_index.embedding` indexes
the generated Wiki-page mirror inside DeerFlow. Do not couple these two model
choices. `GET /api/llmwiki/embedding-models` returns a strict credential-free
projection of WeKnora Embedding models, and KB creation must validate and pass
the selected active model id. When the caller omits a choice, retain WeKnora's
active-default behavior. Existing KBs cannot change this binding in place, and
cross-KB WeKnora searches should use model-compatible knowledge bases.
Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`. Ordinary knowledge-base cards expose an owner/admin-only name/description edit dialog in `WeKnoraKnowledgeWorkspace.tsx`, reusing `useLlmWikiActions().update` and `PATCH /api/llmwiki/knowledge-bases/{id}`. The route trims names, rejects blank or reserved conversation-deposit names, writes to WeKnora before updating local metadata, and retains existing write authorization. A successful remote list is authoritative for every list scope, including `personal`: absent remote IDs are hidden without deleting mappings/references. Upstream failures remain errors. The grid also filters cached `remote_status=missing` rows and no longer renders stale-record cleanup cards. Regression coverage: `tests/test_llmwiki_knowledge_management.py`.
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`. `llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
@@ -1358,14 +1336,6 @@ Automatic conversation summarization when approaching token limits:
when it has already been folded into the hidden summary. This keeps strict when it has already been folded into the hidden summary. This keeps strict
OpenAI-compatible gateways from rejecting a tool-continuation call with OpenAI-compatible gateways from rejecting a tool-continuation call with
`No user query found in message`. `No user query found in message`.
- LangChain keeps the agent prompt in `ModelRequest.system_message`, separate
from `ModelRequest.messages`. Compression must merge its hidden summary into
that dedicated slot and remove every `SystemMessage` from the ordinary
message list. Strict Qwen-style adapters accept only one system message at
index zero and otherwise return HTTP 400 (`system message must be at the
beginning`). Keep this normalization adapter-independent; the payload-level
OpenAI-compatible normalizer is a second line of defence and also merges
adjacent leading system messages.
- Keeps recent messages while summarizing older ones. - Keeps recent messages while summarizing older ones.
Tests: `tests/test_summarization_toggle.py` (gating + whitelist), Tests: `tests/test_summarization_toggle.py` (gating + whitelist),
@@ -1845,8 +1815,8 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
pages need backfill, not a forced rebuild of every page. pages need backfill, not a forced rebuild of every page.
FastEmbed is a standard backend dependency. Encoder configuration, directory FastEmbed is a standard backend dependency. Encoder configuration, directory
upload and warmup live only in the administrator global settings dialog. upload and warmup live only in the administrator global settings dialog.
When the effective provider is local, its `system_settings.json` path override wins over config.yaml; uploaded A runtime `system_settings.json` path override wins over config.yaml; uploaded
files are streamed into immutable `.deer-flow/models/bge-m3-revisions/<revision>` directories, never the source tree. files are streamed into `.deer-flow/models/bge-m3`, never the source tree.
Encoder warmup is explicitly started from the settings UI/API and must never block application startup Encoder warmup is explicitly started from the settings UI/API and must never block application startup
or turn a model-load failure into a DeerFlow service failure. Local provider or turn a model-load failure into a DeerFlow service failure. Local provider
is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and is offline-only: require local_model_path or DEERFLOW_BGE_M3_MODEL_PATH and
@@ -1854,21 +1824,6 @@ Ordinary knowledge detail landing is globally configured by `ordinary_knowledge_
`local_process_isolation=true` by default: inference runs in the bundled JSONL `local_process_isolation=true` by default: inference runs in the bundled JSONL
worker subprocess, and shutdown must terminate that worker without waiting on worker subprocess, and shutdown must terminate that worker without waiting on
model teardown. model teardown.
- The administrator encoder override is an explicit persisted mode, not an
inference from whether `model_path` happens to be non-empty. `configured`
uses the current `config.yaml` provider (including OpenAI-compatible);
saving/uploading a local directory sets `local` plus a new model revision,
while `/api/llmwiki/local-encoder/use-configured` switches back. Every switch
hot-rebuilds embedding/cache/search/sync/retrieval as one graph and updates
the harness runtime registration. Status must remain readable for a remote
provider. The scheduler stays alive as a lightweight config watcher but only
scans when effective `enabled && auto_sync`; keep the default/portable release
configs at `auto_sync=true` because asynchronous Wiki generation can finish
after the event-triggered first scan. A changed local model revision must
change the embedding fingerprint so vectors from replaced weights are never
reused. Uploaded weights live in immutable revision directories. A replaced
embedding generation gets a bounded grace period for in-flight work, then
rejects stragglers, drains real thread work, and releases its model process.
- Every successful mapping snapshot is mirrored to the global assistant base, - Every successful mapping snapshot is mirrored to the global assistant base,
including empty snapshots so deleted source pages are retired. Re-import is including empty snapshots so deleted source pages are retired. Re-import is
source-replacement semantics: absent Wiki/chunk/entity/relation pages must not source-replacement semantics: absent Wiki/chunk/entity/relation pages must not
+4 -32
View File
@@ -51,14 +51,6 @@ Health probes are always unauthenticated, even when API authentication is enable
## Core Components ## 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 ### Lead Agent
The single LangGraph agent (`lead_agent`) is the runtime entry point, created via `make_lead_agent(config)`. It combines: 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 ### 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. - 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) ### AgentScope report collaboration (in progress)
@@ -87,7 +79,7 @@ Middlewares execute in strict order, each handling a specific concern:
| 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) | | 1 | **ThreadDataMiddleware** | Creates per-thread isolated directories (workspace, uploads, outputs) |
| 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context | | 2 | **UploadsMiddleware** | Injects newly uploaded files into conversation context |
| 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution | | 3 | **SandboxMiddleware** | Acquires sandbox environment for code execution |
| 4 | **SummarizationMiddleware** | Reduces context near token limits and folds the hidden summary into one leading system message for strict model adapters (optional) | | 4 | **SummarizationMiddleware** | Reduces context when approaching token limits (optional) |
| 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) | | 5 | **TodoListMiddleware** | Tracks multi-step tasks in plan mode (optional) |
| 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange | | 6 | **TitleMiddleware** | Auto-generates conversation titles after first exchange |
| 7 | **MemoryMiddleware** | Queues conversations for async memory extraction | | 7 | **MemoryMiddleware** | Queues conversations for async memory extraction |
@@ -936,13 +928,6 @@ the file through the existing thread-artifact endpoint.
远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。 远端已经删除的知识库会在下次列表刷新时自动隐藏,包括个人、公共列表和知识库选择器;页面每 15 秒自动刷新,也可以手动点击「刷新」。远端服务请求失败时展示错误,不把连接故障当成删除,不自动删除本地映射或已有引用。
新建知识库时会通过 `GET /api/llmwiki/embedding-models` 读取 WeKnora 中可用的
Embedding 模型,并把所选 `embedding_model_id` 明确传给创建接口。该模型只负责
WeKnora 中上传原文的分块向量化;它和下文 DeerFlow 自己的 Wiki 镜像编码器是两套
独立配置,因此选择 OpenAI/OpenAI-compatible 原文编码器时不要求同时启用本地
BGE-M3。模型列表响应只暴露名称、provider、状态等白名单字段,不返回密钥或连接
参数。模型绑定只对新建库生效;需要跨知识库检索时,各库应使用相同或兼容的向量模型。
## DeerFlow-local LLMWiki Wiki vector index ## DeerFlow-local LLMWiki Wiki vector index
LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only LLMWiki can mirror processed WeKnora Wiki pages into DeerFlow and search only
@@ -1191,23 +1176,13 @@ llmwiki:
本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过 本地编码依赖随标准后端安装。将完整 BGE-M3 模型目录提前放到离线服务器,并通过
`local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在 `local_model_path` 或 `DEERFLOW_BGE_M3_MODEL_PATH` 指向它。管理员也可在
「设置和更多 → 设置 → Wiki 镜像编码器」中选择服务器目录,或从浏览器上传完整模型目录; 「设置和更多 → 设置 → 本地编码器」中选择服务器目录,或从浏览器上传完整模型目录;
页面配置持久化到运行目录的 `system_settings.json`,上传文件落到 页面配置持久化到运行目录的 `system_settings.json`,上传文件落到
`.deer-flow/models/bge-m3-revisions/<revision>` 的不可变版本目录,均不进入源码仓库。系统强制 `local_files_only`, `.deer-flow/models/bge-m3`,均不进入源码仓库。系统强制 `local_files_only`,
**不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会 **不会访问互联网或下载模型**。管理员可在同一设置页点击「一键启动编码器」异步加载;该操作不挂在应用启动流程,加载失败不会
中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow 中断 DeerFlow 主服务。默认由独立低优先级子进程承载模型内存,正常停止 DeerFlow
时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含 时会一并回收该子进程;编码进程异常退出只会将当前向量任务标记失败。模型目录应包含
`onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。 `onnx/model.onnx`(官方权重约 2.3 GB)及对应 tokenizer/config 文件。权重不进入业务源码仓库。
运行时编码器模式持久化为 `configured` 或 `local`。默认 `configured` 严格使用
`config.yaml` 中的 OpenAI、OpenAI-compatible 或 local provider;保存服务器目录或
上传模型会显式切到 `local` 并热重建 embedding/search/sync/retrieval 服务,不需要
重启 Gateway。`POST /api/llmwiki/local-encoder/use-configured` 可立即恢复配置文件中的
provider。`GET /api/llmwiki/local-encoder/status` 在远程 provider 下也正常返回当前
provider/mode,不再把“不是 local”当成状态接口错误。每次激活本地模型都会生成新的
模型修订指纹,避免同一路径替换权重后误复用旧向量。切换后的旧编码器会给在途任务
保留短暂宽限期,随后等待当前编码调用结束并自动回收模型进程。
浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向 浏览器目录上传采用磁盘暂存和 4 MB 分块复制,页面显示上传进度;经 Nginx 等反向
代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。 代理部署时仍需把请求体上限和读写超时配置到可承载完整模型目录。
相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。 相同模型指纹、相同内容的页面复用现有向量;短正文/摘要至少保留一个非空分段。
@@ -1260,6 +1235,3 @@ checkpointer:
normalized to the openGauss dialect. Do not configure the LangGraph normalized to the openGauss dialect. Do not configure the LangGraph
PostgreSQL checkpointer against GaussDB: its migrations are PostgreSQL-specific. PostgreSQL checkpointer against GaussDB: its migrations are PostgreSQL-specific.
For a production image, build with `--build-arg UV_EXTRAS=gauss`. 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
@@ -1615,6 +1615,7 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
from app.gateway.routers import wiki_packages from app.gateway.routers import wiki_packages
app.include_router(wiki_packages.router) app.include_router(wiki_packages.router)
# Artifacts API is mounted at /api/threads/{thread_id}/artifacts # Artifacts API is mounted at /api/threads/{thread_id}/artifacts
app.include_router(artifacts.router) app.include_router(artifacts.router)
@@ -1625,10 +1626,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
app.include_router(browser_context.router) app.include_router(browser_context.router)
# Thread cleanup API is mounted at /api/threads/{thread_id} # 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) app.include_router(threads.router)
# Agents API is mounted at /api/agents # Agents API is mounted at /api/agents
app.include_router(agents.router) app.include_router(agents.router)
app.include_router(strategy_agents.router) app.include_router(strategy_agents.router)
@@ -335,13 +335,8 @@ def require_permission(
from app.gateway.deps import get_thread_store from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request) thread_store = get_thread_store(request)
user_id = str(auth.user.id) user_id = str(auth.user.id)
project_store = getattr(request.app.state, "analysis_project_store", None) allowed = await thread_store.check_access(
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, thread_id,
user_id, user_id,
require_existing=require_existing, require_existing=require_existing,
@@ -307,6 +307,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# Candidate plans are durable user-editable drafts, deliberately kept # Candidate plans are durable user-editable drafts, deliberately kept
# separate from both the canvas draft and a formal workflow run. # separate from both the canvas draft and a formal workflow run.
app.state.workflow_planning_store = make_workflow_planning_store(sf) app.state.workflow_planning_store = make_workflow_planning_store(sf)
from app.report_collaboration.execution.live_hub import PublishingReportCollaborationStore, ReportCollaborationLiveHub from app.report_collaboration.execution.live_hub import PublishingReportCollaborationStore, ReportCollaborationLiveHub
from deerflow.persistence.report_collaboration import make_report_collaboration_store 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) 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 from deerflow.persistence.report_structures import make_report_structure_store
app.state.report_structure_store = make_report_structure_store(sf) app.state.report_structure_store = make_report_structure_store(sf)
@@ -394,6 +391,7 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# DeerFlow-local Wiki mirror/vector index. The store is always wired so # DeerFlow-local Wiki mirror/vector index. The store is always wired so
# migrations and status inspection work while feature flags are off; # migrations and status inspection work while feature flags are off;
# network/search services only exist when explicitly enabled. # network/search services only exist when explicitly enabled.
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.persistence.llmwiki_index import make_llmwiki_index_store from deerflow.persistence.llmwiki_index import make_llmwiki_index_store
local_index_config = config.llmwiki.local_wiki_index local_index_config = config.llmwiki.local_wiki_index
@@ -406,12 +404,78 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.llmwiki_vector_search = None app.state.llmwiki_vector_search = None
app.state.llmwiki_sync_service = None app.state.llmwiki_sync_service = None
app.state.llmwiki_retrieval_service = None app.state.llmwiki_retrieval_service = None
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
# Build every embedding-dependent object through the same hot-swap resolved_llmwiki = get_resolved_llmwiki_runtime(config)
# path used by the administrator settings API. This keeps startup, weknora_client = build_weknora_client(resolved_llmwiki) if resolved_llmwiki.weknora_enabled else None
# uploaded-local selection and config.yaml reloads behaviorally equal. embedding_config = local_index_config.embedding
await rebuild_local_wiki_index_runtime(app, force=True) # The administrator may select or upload the offline encoder from the
# UI. This runtime-owned value deliberately overrides config.yaml and
# survives application restarts without modifying release assets.
if embedding_config.provider == "local":
from deerflow.config.system_settings import load_system_settings
runtime_model_path = load_system_settings().local_wiki_encoder.model_path.strip()
if runtime_model_path:
embedding_config = embedding_config.model_copy(
update={"local_model_path": runtime_model_path}
)
embedding_configured = bool(
embedding_config.enabled
and (embedding_config.base_url or embedding_config.provider == "local")
and embedding_config.model.strip()
)
if embedding_configured:
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(embedding_config)
if local_index_config.enabled and app.state.llmwiki_embedding is not None:
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
app.state.llmwiki_vector_cache = WikiVectorCache(
app.state.llmwiki_index_store,
max_knowledge_bases=local_index_config.cache_max_knowledge_bases,
revision_check_seconds=local_index_config.cache_revision_check_seconds,
)
app.state.llmwiki_vector_search = WikiVectorSearchService(
local_index_config,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
)
if weknora_client is not None:
app.state.llmwiki_sync_service = WikiSyncService(
local_index_config,
app.state.llmwiki_index_store,
app.state.llmwiki_embedding,
app.state.llmwiki_vector_cache,
weknora_client,
)
from app.gateway.knowledge_transfer import sync_to_global
async def on_wiki_vectorized(mapping):
return await sync_to_global(app, mapping)
app.state.llmwiki_sync_service.on_completed = on_wiki_vectorized
if weknora_client is not None or app.state.llmwiki_vector_search is not None:
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
app.state.llmwiki_retrieval_service = WikiRetrievalService(
app.state.llmwiki_index_store,
vector_search=(
app.state.llmwiki_vector_search
if local_index_config.internal_search_enabled
else None
),
client=weknora_client,
)
register_local_wiki_index_runtime(
store=app.state.llmwiki_index_store,
search=app.state.llmwiki_vector_search,
sync=app.state.llmwiki_sync_service,
retrieval=app.state.llmwiki_retrieval_service,
)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
@@ -650,9 +714,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None) register_local_wiki_index_runtime(store=None, search=None, sync=None, retrieval=None)
from app.gateway.llmwiki_index_runtime import close_local_wiki_index_runtime llmwiki_embedding = getattr(app.state, "llmwiki_embedding", None)
if llmwiki_embedding is not None:
await close_local_wiki_index_runtime(app) await llmwiki_embedding.close()
from deerflow.persistence.notifications import set_notification_store from deerflow.persistence.notifications import set_notification_store
set_notification_store(None) set_notification_store(None)
@@ -1,369 +0,0 @@
"""Hot-swappable runtime composition for the DeerFlow-local Wiki index.
The file configuration remains the source of truth for the normal embedding
provider. Administrators can temporarily select the uploaded offline BGE-M3
encoder through ``system_settings.json`` without rewriting release assets or
restarting Gateway.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
from pathlib import Path
from typing import Any
from fastapi import FastAPI
from deerflow.config import get_app_config
from deerflow.config.app_config import AppConfig
from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.config.system_settings import LocalWikiEncoderSettings, load_system_settings
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
logger = logging.getLogger(__name__)
_EMBEDDING_RETIREMENT_GRACE_SECONDS = 600.0
def resolve_effective_local_wiki_index_config(
config: AppConfig,
encoder_settings: LocalWikiEncoderSettings,
) -> LocalWikiIndexConfig:
"""Apply the persisted encoder mode to a copy of the file configuration."""
local_index = config.llmwiki.local_wiki_index
configured_embedding = local_index.embedding
runtime_model_path = encoder_settings.model_path.strip()
if encoder_settings.mode == "local":
# Directory upload is intentionally a BGE-M3 contract. A configured
# OpenAI model/dimension must not leak into the local FastEmbed worker.
embedding = configured_embedding.model_copy(
update={
"enabled": True,
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"base_url": "",
"api_key": "",
"local_model_path": runtime_model_path or configured_embedding.local_model_path,
"local_model_revision": encoder_settings.model_revision,
}
)
elif configured_embedding.provider == "local":
# Keep the historical invariant: an administrator-selected directory
# overrides config.yaml when the configured provider itself is local.
updates = {"local_model_revision": encoder_settings.model_revision}
if runtime_model_path:
updates["local_model_path"] = runtime_model_path
embedding = configured_embedding.model_copy(
update=updates
)
else:
embedding = configured_embedding
return local_index.model_copy(update={"embedding": embedding})
def _resolved_local_model_path(config: LocalWikiIndexConfig) -> Path | None:
raw = config.embedding.local_model_path.strip() or os.environ.get(
"DEERFLOW_BGE_M3_MODEL_PATH", ""
).strip()
if not raw:
return None
path = Path(raw).expanduser()
return path.resolve() if path.is_absolute() else (Path.cwd() / path).resolve()
def _local_model_files_ready(config: LocalWikiIndexConfig) -> bool:
path = _resolved_local_model_path(config)
return bool(
path
and path.is_dir()
and ((path / "onnx" / "model.onnx").is_file() or (path / "model.onnx").is_file())
)
def _embedding_provider_ready(config: LocalWikiIndexConfig) -> bool:
embedding = config.embedding
if embedding.provider == "local":
return _local_model_files_ready(config)
return bool(embedding.base_url)
def _schedule_embedding_retirement(app: FastAPI, embedding: Any) -> None:
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
if embedding not in retired:
retired.append(embedding)
app.state.llmwiki_retired_embeddings = retired
async def retire_after_grace() -> None:
retired_successfully = False
try:
await asyncio.sleep(_EMBEDDING_RETIREMENT_GRACE_SECONDS)
await embedding.retire()
retired_successfully = True
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Failed to retire an old Local Wiki embedding runtime")
finally:
if retired_successfully:
current = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
app.state.llmwiki_retired_embeddings = [item for item in current if item is not embedding]
tasks: set[asyncio.Task[None]] = getattr(app.state, "llmwiki_retirement_tasks", None)
if tasks is None:
tasks = set()
app.state.llmwiki_retirement_tasks = tasks
task = asyncio.create_task(retire_after_grace(), name="llmwiki-embedding-retirement")
tasks.add(task)
task.add_done_callback(tasks.discard)
def _runtime_signature(
local_index: LocalWikiIndexConfig,
*,
resolved_llmwiki: Any,
encoder_mode: str,
) -> str:
payload = {
"local_index": local_index.model_dump(mode="json"),
# A directory can be mounted/unmounted without editing config.yaml.
# Readiness is part of the generation so services appear/disappear on
# the next lightweight scheduler poll instead of requiring a restart.
"local_model_files_ready": (
_local_model_files_ready(local_index)
if local_index.embedding.provider == "local"
else None
),
"encoder_mode": encoder_mode,
"weknora": {
"provider": resolved_llmwiki.provider,
"api_base_url": resolved_llmwiki.api_base_url,
"web_base_url": resolved_llmwiki.web_base_url,
"admin_email": resolved_llmwiki.admin_email,
"admin_password": resolved_llmwiki.admin_password,
"override_enabled": resolved_llmwiki.override_enabled,
},
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def rebuild_local_wiki_index_runtime(
app: FastAPI,
*,
force: bool = False,
) -> LocalWikiIndexConfig:
"""Refresh config and atomically replace all embedding-dependent services."""
lock = getattr(app.state, "llmwiki_runtime_lock", None)
if lock is None:
lock = asyncio.Lock()
app.state.llmwiki_runtime_lock = lock
async with lock:
source_config = get_app_config()
settings = load_system_settings()
effective_config = resolve_effective_local_wiki_index_config(
source_config,
settings.local_wiki_encoder,
)
resolved_llmwiki = get_resolved_llmwiki_runtime(source_config)
signature = _runtime_signature(
effective_config,
resolved_llmwiki=resolved_llmwiki,
encoder_mode=settings.local_wiki_encoder.mode,
)
current_signature = getattr(app.state, "llmwiki_runtime_signature", None)
if not force and current_signature == signature:
app.state.llmwiki_source_config = source_config
app.state.llmwiki_effective_config = effective_config
return effective_config
index_store = getattr(app.state, "llmwiki_index_store", None)
if index_store is None:
raise RuntimeError("Local Wiki index store is not initialized")
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.retrieval import WikiRetrievalService
from deerflow.integrations.weknora.local_index.runtime import register_local_wiki_index_runtime
from deerflow.integrations.weknora.local_index.search import WikiVectorSearchService
from deerflow.integrations.weknora.local_index.sync import WikiSyncService
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
embedding_config = effective_config.embedding
provider_ready = _embedding_provider_ready(effective_config)
embedding_configured = bool(
embedding_config.enabled
and provider_ready
and embedding_config.model.strip()
)
old_embedding = getattr(app.state, "llmwiki_embedding", None)
reusable = (
old_embedding
if old_embedding is not None and old_embedding.config == embedding_config
else None
)
new_embedding = None
if embedding_configured:
new_embedding = reusable or StrictWikiEmbeddingClient(embedding_config)
new_cache = None
new_search = None
new_sync = None
new_retrieval = None
weknora_client = (
build_weknora_client(resolved_llmwiki)
if resolved_llmwiki.weknora_enabled
else None
)
if effective_config.enabled and new_embedding is not None:
new_cache = WikiVectorCache(
index_store,
max_knowledge_bases=effective_config.cache_max_knowledge_bases,
revision_check_seconds=effective_config.cache_revision_check_seconds,
)
new_search = WikiVectorSearchService(
effective_config,
new_embedding,
new_cache,
)
if weknora_client is not None:
new_sync = WikiSyncService(
effective_config,
index_store,
new_embedding,
new_cache,
weknora_client,
)
from app.gateway.knowledge_transfer import sync_to_global
async def on_wiki_vectorized(mapping):
return await sync_to_global(app, mapping)
new_sync.on_completed = on_wiki_vectorized
if weknora_client is not None or new_search is not None:
new_retrieval = WikiRetrievalService(
index_store,
vector_search=(new_search if effective_config.internal_search_enabled else None),
client=weknora_client,
)
app.state.llmwiki_source_config = source_config
app.state.llmwiki_effective_config = effective_config
app.state.llmwiki_embedding = new_embedding
app.state.llmwiki_vector_cache = new_cache
app.state.llmwiki_vector_search = new_search
app.state.llmwiki_sync_service = new_sync
app.state.llmwiki_retrieval_service = new_retrieval
app.state.llmwiki_runtime_signature = signature
register_local_wiki_index_runtime(
store=index_store,
search=new_search,
sync=new_sync,
retrieval=new_retrieval,
)
if old_embedding is not None and old_embedding is not new_embedding:
# Give requests holding the prior service graph time to finish,
# then reject any stragglers and release the potentially multi-GB
# local worker instead of accumulating one process per upload.
_schedule_embedding_retirement(app, old_embedding)
return effective_config
async def close_local_wiki_index_runtime(app: FastAPI) -> None:
"""Close the current and safely-retired embedding clients at shutdown."""
retired = list(getattr(app.state, "llmwiki_retired_embeddings", []) or [])
retirement_tasks = list(getattr(app.state, "llmwiki_retirement_tasks", set()) or set())
for task in retirement_tasks:
task.cancel()
if retirement_tasks:
await asyncio.gather(*retirement_tasks, return_exceptions=True)
app.state.llmwiki_retirement_tasks = set()
current = getattr(app.state, "llmwiki_embedding", None)
seen: set[int] = set()
for embedding in [current, *retired]:
if embedding is None or id(embedding) in seen:
continue
seen.add(id(embedding))
await embedding.close()
app.state.llmwiki_retired_embeddings = []
def local_encoder_runtime_status(app: FastAPI) -> dict[str, object]:
"""Return one stable status contract for remote and local providers."""
source_config: AppConfig | None = getattr(app.state, "llmwiki_source_config", None)
if source_config is None:
source_config = app.state.config
configured = source_config.llmwiki.local_wiki_index.embedding
effective: LocalWikiIndexConfig = getattr(
app.state,
"llmwiki_effective_config",
source_config.llmwiki.local_wiki_index,
)
settings = load_system_settings().local_wiki_encoder
embedding = getattr(app.state, "llmwiki_embedding", None)
if embedding is not None:
status = dict(embedding.local_encoder_status())
else:
status = {
"provider": effective.embedding.provider,
"model": effective.embedding.model,
"dimensions": effective.embedding.dimensions,
"state": "idle",
"running": False,
"last_error": None,
"started_at": None,
"ready_at": None,
"offline_only": effective.embedding.provider == "local",
"model_path_configured": False,
"model_path_exists": False,
"model_files_ready": False,
"process_isolation": effective.embedding.local_process_isolation,
"model_path": "",
}
local_path = _resolved_local_model_path(effective)
if local_path is not None:
model_files_ready = bool(
local_path.is_dir()
and (
(local_path / "onnx" / "model.onnx").is_file()
or (local_path / "model.onnx").is_file()
)
)
status.update(
{
"model_path": str(local_path),
"model_path_configured": True,
"model_path_exists": local_path.is_dir(),
"model_files_ready": model_files_ready,
}
)
status.update(
{
"provider": effective.embedding.provider,
"configured_provider": configured.provider,
"mode": settings.mode,
"using_local": effective.embedding.provider == "local",
"offline_only": effective.embedding.provider == "local",
}
)
return status
__all__ = [
"close_local_wiki_index_runtime",
"local_encoder_runtime_status",
"rebuild_local_wiki_index_runtime",
"resolve_effective_local_wiki_index_config",
]
@@ -5,13 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from collections.abc import Coroutine from collections.abc import Coroutine
from time import monotonic
from typing import Any from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CONFIG_REFRESH_SECONDS = 10.0
def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task: def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]) -> asyncio.Task:
@@ -27,27 +25,12 @@ def spawn_local_wiki_index_job(app: FastAPI, coroutine: Coroutine[Any, Any, Any]
return task return task
async def _refresh_local_wiki_config(app: FastAPI): async def _sync_once(app: FastAPI) -> None:
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
return await rebuild_local_wiki_index_runtime(app)
def _auto_sync_ready(app: FastAPI, config: Any) -> bool:
return bool(
config.enabled
and config.auto_sync
and getattr(app.state, "llmwiki_sync_service", None) is not None
and getattr(app.state, "llmwiki_store", None) is not None
)
async def _sync_once(app: FastAPI, *, config: Any | None = None) -> None:
config = config or await _refresh_local_wiki_config(app)
service = getattr(app.state, "llmwiki_sync_service", None) service = getattr(app.state, "llmwiki_sync_service", None)
mapping_store = getattr(app.state, "llmwiki_store", None) mapping_store = getattr(app.state, "llmwiki_store", None)
if not _auto_sync_ready(app, config) or service is None or mapping_store is None: if service is None or mapping_store is None:
return return
config = app.state.config.llmwiki.local_wiki_index
mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)] mappings = [row for row in await mapping_store.list_visible("system", scope="all", is_admin=True) if row.get("wiki_index_enabled", True)]
semaphore = asyncio.Semaphore(config.sync_concurrency) semaphore = asyncio.Semaphore(config.sync_concurrency)
@@ -64,42 +47,16 @@ async def _sync_once(app: FastAPI, *, config: Any | None = None) -> None:
async def local_wiki_index_scheduler_loop(app: FastAPI) -> None: async def local_wiki_index_scheduler_loop(app: FastAPI) -> None:
interval = app.state.config.llmwiki.local_wiki_index.sync_interval_seconds
logger.info( logger.info(
"Local Wiki auto-sync scheduler started; runtime config refresh interval=%s seconds", "Local Wiki auto-sync scheduler started; first scan is immediate, interval=%s seconds",
_CONFIG_REFRESH_SECONDS, interval,
) )
last_sync_started_at: float | None = None
last_synced_runtime_signature: str | None = None
while True: while True:
try: try:
# Rebuild checks both config.yaml mtime and persisted runtime await _sync_once(app)
# settings every poll, independently from the much longer sync
# cadence. Provider/URL changes therefore take effect promptly.
config = await _refresh_local_wiki_config(app)
if not _auto_sync_ready(app, config):
# A later disabled -> enabled transition is a new first valid
# configuration and should receive an immediate scan.
last_sync_started_at = None
last_synced_runtime_signature = None
else:
now = monotonic()
interval = float(config.sync_interval_seconds)
runtime_signature = getattr(
app.state,
"llmwiki_runtime_signature",
None,
)
if (
last_sync_started_at is None
or runtime_signature != last_synced_runtime_signature
or now - last_sync_started_at >= interval
):
sync_started_at = now
await _sync_once(app, config=config)
last_sync_started_at = sync_started_at
last_synced_runtime_signature = runtime_signature
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception: except Exception:
logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)") logger.exception("Local Wiki auto-sync scheduler tick failed (non-fatal)")
await asyncio.sleep(_CONFIG_REFRESH_SECONDS) await asyncio.sleep(interval)
@@ -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: ) -> None:
mapping_id = str(mapping.get("id") or mapping.get("knowledge_base_id") or "") mapping_id = str(mapping.get("id") or mapping.get("knowledge_base_id") or "")
service = getattr(request.app.state, "llmwiki_sync_service", None) service = getattr(request.app.state, "llmwiki_sync_service", None)
if not mapping_id: if service is None or not mapping_id:
raise RuntimeError("请先配置 Wiki 编码模型并启用本地向量索引,再生成向量数据包") 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 # Refresh remote metadata/content hashes even when the previous generation
# was complete. Unchanged articles reuse their existing vectors. # was complete. Unchanged articles reuse their existing vectors.
sync_mapping = dict(mapping) sync_mapping = dict(mapping)
@@ -181,7 +181,6 @@ class KnowledgeBaseCreate(BaseModel):
description: str = Field(default="", max_length=2048) description: str = Field(default="", max_length=2048)
type: Literal["document", "faq"] = "document" type: Literal["document", "faq"] = "document"
wiki_enabled: bool = False wiki_enabled: bool = False
embedding_model_id: str | None = Field(default=None, min_length=1, max_length=255)
class KnowledgeBaseUpdate(BaseModel): class KnowledgeBaseUpdate(BaseModel):
@@ -252,9 +251,7 @@ def _store(request: Request) -> LlmWikiStore:
def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None: def _schedule_local_wiki_sync(request: Request, mapping: dict[str, Any]) -> None:
service = getattr(request.app.state, "llmwiki_sync_service", None) service = getattr(request.app.state, "llmwiki_sync_service", None)
config = getattr(request.app.state, "llmwiki_effective_config", None) config = request.app.state.config.llmwiki.local_wiki_index
if config is None:
config = request.app.state.config.llmwiki.local_wiki_index
if service is not None and config.enabled and config.auto_sync and mapping.get("wiki_index_enabled", True): if service is not None and config.enabled and config.auto_sync and mapping.get("wiki_index_enabled", True):
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
@@ -303,74 +300,6 @@ def _raise_upstream(exc: WeKnoraError) -> None:
raise HTTPException(status_code=status, detail=str(exc)) from None raise HTTPException(status_code=status, detail=str(exc)) from None
def _embedding_model_view(source: dict[str, Any]) -> dict[str, Any] | None:
"""Return the small, credential-free model shape needed by the create dialog."""
if str(source.get("type") or "").strip().lower() != "embedding":
return None
model_id = str(source.get("id") or "").strip()
if not model_id:
return None
status = str(source.get("status") or "active").strip().lower() or "active"
explicitly_available = source.get("available")
available = (
bool(explicitly_available)
if isinstance(explicitly_available, bool)
else status in {"active", "available", "enabled", "ready"}
)
parameters = source.get("parameters") if isinstance(source.get("parameters"), dict) else {}
def safe_text(*keys: str) -> str:
for key in keys:
value = source.get(key)
if isinstance(value, (str, int, float)) and str(value).strip():
return str(value).strip()
return ""
nested_provider = parameters.get("provider")
provider = safe_text("provider", "provider_type")
if not provider and isinstance(nested_provider, str):
provider = nested_provider.strip()
if not provider:
provider = safe_text("source", "platform")
nested_model = parameters.get("model") or parameters.get("model_name")
model_name = safe_text("model_name", "model")
if not model_name and isinstance(nested_model, str):
model_name = nested_model.strip()
return {
"id": model_id,
"name": safe_text("display_name", "name", "model_name", "model") or model_name or model_id,
"model_name": model_name,
"provider": provider,
"status": status,
"is_default": source.get("is_default") is True,
"available": available,
}
async def _embedding_models(client: WeKnoraClient) -> list[dict[str, Any]]:
return [view for item in await client.list_models() if (view := _embedding_model_view(item)) is not None]
def _select_embedding_model(models: list[dict[str, Any]], requested_id: str | None) -> str:
available = [item for item in models if item["available"]]
if requested_id:
selected = next((item for item in models if item["id"] == requested_id), None)
if selected is None:
raise WeKnoraError("The selected WeKnora embedding model does not exist", status_code=400)
if not selected["available"]:
raise WeKnoraError("The selected WeKnora embedding model is unavailable", status_code=400)
return requested_id
selected = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
if selected is None:
raise WeKnoraError(
"WeKnora has no available embedding model; configure one in WeKnora before creating a knowledge base",
status_code=400,
)
return str(selected["id"])
def _json_response(payload: Any, *, status_code: int = 200) -> Response: def _json_response(payload: Any, *, status_code: int = 200) -> Response:
return Response( return Response(
content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"), content=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
@@ -380,10 +309,7 @@ def _json_response(payload: Any, *, status_code: int = 200) -> Response:
def _runtime_for_iframe_proxy(request: Request | None = None): def _runtime_for_iframe_proxy(request: Request | None = None):
source_config = None runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None) if request is not None else None)
if request is not None:
source_config = getattr(request.app.state, "llmwiki_source_config", None)
runtime = get_resolved_llmwiki_runtime(source_config)
if not runtime.weknora_enabled: if not runtime.weknora_enabled:
raise HTTPException(status_code=409, detail="WeKnora mode is not enabled") raise HTTPException(status_code=409, detail="WeKnora mode is not enabled")
if not runtime.web_base_url: if not runtime.web_base_url:
@@ -2559,9 +2485,7 @@ async def get_llmwiki_runtime() -> dict[str, Any]:
@router.get("/api/system-settings/llmwiki") @router.get("/api/system-settings/llmwiki")
async def get_llmwiki_settings(request: Request) -> dict[str, Any]: async def get_llmwiki_settings(request: Request) -> dict[str, Any]:
await _require_admin(request) await _require_admin(request)
from deerflow.config import get_app_config app_config = request.app.state.config
app_config = get_app_config()
runtime = get_resolved_llmwiki_runtime(app_config) runtime = get_resolved_llmwiki_runtime(app_config)
return { return {
**_runtime_or_legacy(), **_runtime_or_legacy(),
@@ -2584,9 +2508,6 @@ async def update_llmwiki_settings(request: Request, body: LlmWikiSettingsUpdate)
raise HTTPException(status_code=422, detail=str(exc)) from None raise HTTPException(status_code=422, detail=str(exc)) from None
current = load_system_settings() current = load_system_settings()
save_system_settings(current.model_copy(update={"llmwiki": override})) save_system_settings(current.model_copy(update={"llmwiki": override}))
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
await rebuild_local_wiki_index_runtime(request.app, force=True)
return _runtime_or_legacy() return _runtime_or_legacy()
@@ -2595,16 +2516,13 @@ async def reset_llmwiki_settings(request: Request) -> dict[str, Any]:
await _require_admin(request) await _require_admin(request)
current = load_system_settings() current = load_system_settings()
save_system_settings(current.model_copy(update={"llmwiki": LlmWikiRuntimeOverride()})) save_system_settings(current.model_copy(update={"llmwiki": LlmWikiRuntimeOverride()}))
from app.gateway.llmwiki_index_runtime import rebuild_local_wiki_index_runtime
await rebuild_local_wiki_index_runtime(request.app, force=True)
return _runtime_or_legacy() return _runtime_or_legacy()
@router.post("/api/system-settings/llmwiki/test") @router.post("/api/system-settings/llmwiki/test")
async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) -> dict[str, Any]: async def test_llmwiki_connection(request: Request, body: LlmWikiConnectionTest) -> dict[str, Any]:
await _require_admin(request) await _require_admin(request)
runtime = get_resolved_llmwiki_runtime() runtime = get_resolved_llmwiki_runtime(request.app.state.config)
if body.api_base_url is not None: if body.api_base_url is not None:
try: try:
candidate = normalize_service_url(body.api_base_url) candidate = normalize_service_url(body.api_base_url)
@@ -2759,13 +2677,11 @@ def _public_local_index_summary(
state: dict[str, Any] | None, state: dict[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
app_state = request.app.state app_state = request.app.state
local_config = getattr(app_state, "llmwiki_effective_config", None) local_config = getattr(
if local_config is None: getattr(getattr(app_state, "config", None), "llmwiki", None),
local_config = getattr( "local_wiki_index",
getattr(getattr(app_state, "config", None), "llmwiki", None), None,
"local_wiki_index", )
None,
)
enabled = bool(getattr(local_config, "enabled", False)) enabled = bool(getattr(local_config, "enabled", False))
current = dict(state or {}) current = dict(state or {})
state_name = str(current.get("state") or "not_synced") state_name = str(current.get("state") or "not_synced")
@@ -3084,21 +3000,6 @@ async def list_knowledge_bases(
return {"knowledge_bases": knowledge_bases} return {"knowledge_bases": knowledge_bases}
@router.get("/api/llmwiki/embedding-models")
async def list_embedding_models(request: Request) -> dict[str, Any]:
"""List selectable WeKnora embedding models without exposing model secrets."""
await _actor(request)
try:
models = await _embedding_models(_client_or_503())
except WeKnoraError as exc:
_raise_upstream(exc)
available = [item for item in models if item["available"]]
default_model = next((item for item in available if item["is_default"]), None) or next(iter(available), None)
default_model_id = str(default_model["id"]) if default_model is not None else None
return {"models": models, "default_model_id": default_model_id}
@router.get("/api/llmwiki/agents/{agent_id}/knowledge-bases") @router.get("/api/llmwiki/agents/{agent_id}/knowledge-bases")
async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[str, Any]: async def list_agent_knowledge_bases(request: Request, agent_id: str) -> dict[str, Any]:
"""Return the agent-bound bases still visible to the current DeerFlow user.""" """Return the agent-bound bases still visible to the current DeerFlow user."""
@@ -3137,16 +3038,11 @@ async def create_knowledge_base(request: Request, body: KnowledgeBaseCreate) ->
if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME: if body.name.strip() == CONVERSATION_DEPOSIT_KB_NAME:
raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库") raise HTTPException(status_code=409, detail="对话沉淀是 cmzs 系统保留知识库")
try: try:
embedding_model_id = _select_embedding_model(
await _embedding_models(client),
body.embedding_model_id,
)
remote = await client.create_knowledge_base( remote = await client.create_knowledge_base(
name=body.name.strip(), name=body.name.strip(),
description=body.description, description=body.description,
kb_type=body.type, kb_type=body.type,
wiki_enabled=body.wiki_enabled, wiki_enabled=body.wiki_enabled,
embedding_model_id=embedding_model_id,
) )
except WeKnoraError as exc: except WeKnoraError as exc:
_raise_upstream(exc) _raise_upstream(exc)
@@ -13,10 +13,6 @@ from fastapi import APIRouter, File, Form, HTTPException, Query, Request, Upload
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request from app.gateway.deps import get_optional_user_from_request
from app.gateway.llmwiki_index_runtime import (
local_encoder_runtime_status,
rebuild_local_wiki_index_runtime,
)
from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job from app.gateway.llmwiki_index_scheduler import spawn_local_wiki_index_job
from deerflow.config.runtime_paths import resolve_path, runtime_home from deerflow.config.runtime_paths import resolve_path, runtime_home
from deerflow.config.system_settings import load_system_settings, save_system_settings from deerflow.config.system_settings import load_system_settings, save_system_settings
@@ -57,8 +53,8 @@ async def _actor(request: Request) -> tuple[str, bool]:
return str(user.id), getattr(user, "system_role", None) == "admin" return str(user.id), getattr(user, "system_role", None) == "admin"
async def _services(request: Request): def _services(request: Request):
config = await rebuild_local_wiki_index_runtime(request.app) config = request.app.state.config.llmwiki.local_wiki_index
if not config.enabled: if not config.enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INDEX_DISABLED", "message": "Local Wiki index is disabled"}) raise HTTPException(status_code=409, detail={"code": "WIKI_INDEX_DISABLED", "message": "Local Wiki index is disabled"})
search = getattr(request.app.state, "llmwiki_vector_search", None) search = getattr(request.app.state, "llmwiki_vector_search", None)
@@ -69,12 +65,12 @@ async def _services(request: Request):
def _local_encoder(request: Request): def _local_encoder(request: Request):
effective = getattr(request.app.state, "llmwiki_effective_config", None) config = request.app.state.config.llmwiki.local_wiki_index.embedding
if effective is None or effective.embedding.provider != "local":
raise HTTPException(status_code=409, detail={"code": "EMBEDDING_NOT_LOCAL", "message": "当前 embedding provider 不是 local"})
embedding = getattr(request.app.state, "llmwiki_embedding", None) embedding = getattr(request.app.state, "llmwiki_embedding", None)
if config.provider != "local":
raise HTTPException(status_code=409, detail={"code": "EMBEDDING_NOT_LOCAL", "message": "当前 embedding provider 不是 local"})
if embedding is None: if embedding is None:
raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器目录未配置、无效或尚未就绪"}) raise HTTPException(status_code=503, detail={"code": "LOCAL_ENCODER_UNAVAILABLE", "message": "本地编码器未配置或未启用"})
return embedding return embedding
@@ -111,35 +107,33 @@ def _copy_encoder_uploads(files: list[UploadFile], paths: list[str], staging: Pa
return total return total
def _activate_uploaded_encoder(staging: Path, model_revision: str) -> Path: def _activate_uploaded_encoder(staging: Path) -> Path:
model_root = (runtime_home() / "models" / "bge-m3-revisions").resolve() model_root = (runtime_home() / "models").resolve()
model_root.mkdir(parents=True, exist_ok=True) model_root.mkdir(parents=True, exist_ok=True)
active = (model_root / model_revision).resolve() active = (model_root / "bge-m3").resolve()
if active.parent != model_root: if active.parent != model_root:
raise RuntimeError("编码器目标目录越界") raise RuntimeError("编码器目标目录越界")
backup = (model_root / f".bge-m3-backup-{uuid4().hex}").resolve()
if active.exists(): if active.exists():
raise RuntimeError("编码器版本目录已存在") active.replace(backup)
staging.replace(active) try:
staging.replace(active)
except Exception:
if backup.exists() and not active.exists():
backup.replace(active)
raise
if backup.exists():
shutil.rmtree(backup)
return active return active
async def _save_encoder_path( async def _save_encoder_path(request: Request, path: Path) -> dict[str, object]:
request: Request, embedding = _local_encoder(request)
path: Path,
*,
model_revision: str | None = None,
) -> dict[str, object]:
current = load_system_settings() current = load_system_settings()
encoder_settings = current.local_wiki_encoder.model_copy( encoder_settings = current.local_wiki_encoder.model_copy(update={"model_path": str(path)})
update={
"mode": "local",
"model_path": str(path),
"model_revision": model_revision or uuid4().hex,
}
)
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings})) save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
await rebuild_local_wiki_index_runtime(request.app, force=True) await embedding.reset_local_encoder(model_path=str(path))
return local_encoder_runtime_status(request.app) return embedding.local_encoder_status()
@router.get("/local-encoder/status") @router.get("/local-encoder/status")
@@ -147,8 +141,7 @@ async def local_encoder_status(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request) _, is_admin = await _actor(request)
if not is_admin: if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required") raise HTTPException(status_code=403, detail="Administrator access required")
await rebuild_local_wiki_index_runtime(request.app) return _local_encoder(request).local_encoder_status()
return local_encoder_runtime_status(request.app)
@router.post("/local-encoder/start", status_code=202) @router.post("/local-encoder/start", status_code=202)
@@ -156,10 +149,8 @@ async def start_local_encoder(request: Request) -> dict[str, object]:
_, is_admin = await _actor(request) _, is_admin = await _actor(request)
if not is_admin: if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required") raise HTTPException(status_code=403, detail="Administrator access required")
await rebuild_local_wiki_index_runtime(request.app)
try: try:
_local_encoder(request).start_local_encoder() return _local_encoder(request).start_local_encoder()
return local_encoder_runtime_status(request.app)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=409, detail={"code": "LOCAL_ENCODER_START_REJECTED", "message": str(exc)}) from None raise HTTPException(status_code=409, detail={"code": "LOCAL_ENCODER_START_REJECTED", "message": str(exc)}) from None
@@ -172,20 +163,6 @@ async def configure_local_encoder(request: Request, body: LocalEncoderConfigRequ
return await _save_encoder_path(request, _validated_encoder_path(body.model_path)) return await _save_encoder_path(request, _validated_encoder_path(body.model_path))
@router.post("/local-encoder/use-configured")
async def use_configured_encoder(request: Request) -> dict[str, object]:
"""Restore the embedding provider declared in config.yaml without restart."""
_, is_admin = await _actor(request)
if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required")
current = load_system_settings()
encoder_settings = current.local_wiki_encoder.model_copy(update={"mode": "configured"})
save_system_settings(current.model_copy(update={"local_wiki_encoder": encoder_settings}))
await rebuild_local_wiki_index_runtime(request.app, force=True)
return local_encoder_runtime_status(request.app)
@router.post("/local-encoder/upload") @router.post("/local-encoder/upload")
async def upload_local_encoder( async def upload_local_encoder(
request: Request, request: Request,
@@ -214,17 +191,9 @@ async def upload_local_encoder(
try: try:
uploaded_bytes = await asyncio.to_thread(_copy_encoder_uploads, files, paths, staging) uploaded_bytes = await asyncio.to_thread(_copy_encoder_uploads, files, paths, staging)
_validated_encoder_path(str(staging)) _validated_encoder_path(str(staging))
model_revision = uuid4().hex await _local_encoder(request).reset_local_encoder()
active = await asyncio.to_thread( active = await asyncio.to_thread(_activate_uploaded_encoder, staging)
_activate_uploaded_encoder, status = await _save_encoder_path(request, active)
staging,
model_revision,
)
status = await _save_encoder_path(
request,
active,
model_revision=model_revision,
)
return {**status, "uploaded_files": len(files), "uploaded_bytes": uploaded_bytes} return {**status, "uploaded_files": len(files), "uploaded_bytes": uploaded_bytes}
except HTTPException: except HTTPException:
raise raise
@@ -255,7 +224,7 @@ async def _authorized_mappings(request: Request, mapping_ids: list[str]) -> tupl
@router.post("/wiki-vector-search") @router.post("/wiki-vector-search")
async def wiki_vector_search(request: Request, body: WikiVectorSearchRequest) -> dict[str, Any]: async def wiki_vector_search(request: Request, body: WikiVectorSearchRequest) -> dict[str, Any]:
config, search, _ = await _services(request) config, search, _ = _services(request)
if not config.internal_search_enabled: if not config.internal_search_enabled:
raise HTTPException(status_code=409, detail={"code": "WIKI_INTERNAL_SEARCH_DISABLED", "message": "Local Wiki search is not enabled"}) raise HTTPException(status_code=409, detail={"code": "WIKI_INTERNAL_SEARCH_DISABLED", "message": "Local Wiki search is not enabled"})
rows, user_id, is_admin = await _authorized_mappings(request, body.knowledge_base_ids) rows, user_id, is_admin = await _authorized_mappings(request, body.knowledge_base_ids)
@@ -281,7 +250,7 @@ async def local_wiki_page(
include_provenance: bool = Query(default=False), include_provenance: bool = Query(default=False),
include_archived: bool = Query(default=False), include_archived: bool = Query(default=False),
) -> dict[str, Any]: ) -> dict[str, Any]:
_, _, index_store = await _services(request) _, _, index_store = _services(request)
user_id, is_admin = await _actor(request) user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin) mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None: if mapping is None:
@@ -323,7 +292,7 @@ async def wiki_index_status(request: Request) -> dict[str, Any]:
_, is_admin = await _actor(request) _, is_admin = await _actor(request)
if not is_admin: if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required") raise HTTPException(status_code=403, detail="Administrator access required")
config = await rebuild_local_wiki_index_runtime(request.app) config = request.app.state.config.llmwiki.local_wiki_index
index_store = getattr(request.app.state, "llmwiki_index_store", None) index_store = getattr(request.app.state, "llmwiki_index_store", None)
if index_store is None: if index_store is None:
raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"}) raise HTTPException(status_code=503, detail={"code": "WIKI_INDEX_UNAVAILABLE"})
@@ -383,7 +352,7 @@ async def _run_one_sync(app, mapping: dict[str, Any], *, force: bool) -> None:
@router.post("/wiki-index/sync", status_code=202) @router.post("/wiki-index/sync", status_code=202)
async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]: async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
await _services(request) _services(request)
_, is_admin = await _actor(request) _, is_admin = await _actor(request)
if not is_admin: if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required") raise HTTPException(status_code=403, detail="Administrator access required")
@@ -394,7 +363,7 @@ async def sync_wiki_index(request: Request, body: WikiIndexActionRequest) -> dic
@router.post("/wiki-index/rebuild", status_code=202) @router.post("/wiki-index/rebuild", status_code=202)
async def rebuild_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]: async def rebuild_wiki_index(request: Request, body: WikiIndexActionRequest) -> dict[str, Any]:
_, _, index_store = await _services(request) _, _, index_store = _services(request)
_, is_admin = await _actor(request) _, is_admin = await _actor(request)
if not is_admin: if not is_admin:
raise HTTPException(status_code=403, detail="Administrator access required") raise HTTPException(status_code=403, detail="Administrator access required")
@@ -419,7 +388,6 @@ async def rebuild_one_wiki_index(request: Request, mapping_id: str, reason: str
@router.get("/knowledge-bases/{mapping_id}/wiki-index-status") @router.get("/knowledge-bases/{mapping_id}/wiki-index-status")
async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) -> dict[str, Any]: async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) -> dict[str, Any]:
config = await rebuild_local_wiki_index_runtime(request.app)
user_id, is_admin = await _actor(request) user_id, is_admin = await _actor(request)
mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin) mapping = await request.app.state.llmwiki_store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
if mapping is None: if mapping is None:
@@ -462,7 +430,7 @@ async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) ->
return { return {
**state, **state,
"knowledge_base_id": mapping_id, "knowledge_base_id": mapping_id,
"local_index_enabled": bool(config.enabled), "local_index_enabled": bool(request.app.state.config.llmwiki.local_wiki_index.enabled),
"wiki_index_enabled": mapping.get("wiki_index_enabled", True), "wiki_index_enabled": mapping.get("wiki_index_enabled", True),
"fully_vectorized": fully_vectorized, "fully_vectorized": fully_vectorized,
"retrieval_mode": "vector" if fully_vectorized else "wiki_api", "retrieval_mode": "vector" if fully_vectorized else "wiki_api",
@@ -480,7 +448,7 @@ async def knowledge_base_wiki_index_status(request: Request, mapping_id: str) ->
@router.post("/knowledge-bases/{mapping_id}/vectorize", status_code=202) @router.post("/knowledge-bases/{mapping_id}/vectorize", status_code=202)
async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]: async def vectorize_knowledge_base(request: Request, mapping_id: str) -> dict[str, Any]:
config = await rebuild_local_wiki_index_runtime(request.app) config = request.app.state.config.llmwiki.local_wiki_index
if not config.enabled: if not config.enabled:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
@@ -71,10 +71,6 @@ class ChainSeatSchema(BaseModel):
# 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排 # 岗位协同专用的席位岗位归属。字段保存在既有 seats JSON 中,普通圆桌编排
# 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。 # 忽略它;显式声明可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
position_id: str | None = Field(default=None, max_length=64) position_id: str | None = Field(default=None, max_length=64)
# 该席位提交过程产物时传给外部任务系统的 position 值(8BF 会商页用)。
# 缺省 None = 提交时回退 0。保存在既有 seats JSON 中,无需迁移;显式声明
# 可确保 Pydantic 在 create/update 的 model_dump() 时不剥掉。
artifact_position: int | None = None
# 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段; # 该席位在业务链中的岗位职责。会商智能体配置面板只读展示此字段;
# 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。 # 保存在既有 seats JSON 中,无需迁移。空/None = 未填写。
job_responsibilities: str | None = Field(default=None, max_length=4000) job_responsibilities: str | None = Field(default=None, max_length=4000)
@@ -318,35 +318,6 @@ class ModelConcurrencyUpdateRequest(BaseModel):
user_model_limits: dict[str, int] | None = None 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: def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
return SkillCompressionResponse( return SkillCompressionResponse(
enabled=settings.enabled, enabled=settings.enabled,
@@ -928,9 +928,6 @@ async def start_run(
router) and ``[leader-timing] / [special-timing]`` (圆桌 leader/special). router) and ``[leader-timing] / [special-timing]`` (圆桌 leader/special).
""" """
t_entry = time.perf_counter() 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) bridge = get_stream_bridge(request)
run_mgr = get_run_manager(request) run_mgr = get_run_manager(request)
run_ctx = get_run_context(request) run_ctx = get_run_context(request)
@@ -951,7 +948,7 @@ async def start_run(
effective_user_id = get_effective_user_id() effective_user_id = get_effective_user_id()
effective_run_scope = _resolve_run_scope(body, request, run_scope) effective_run_scope = _resolve_run_scope(body, request, run_scope)
graph_input = normalize_input(body.input) 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, body=body,
request=request, request=request,
graph_input=graph_input, 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: if value is not None and top_level_key not in body_context:
body_context[top_level_key] = value body_context[top_level_key] = value
merge_run_context_overrides(config, body_context or None) 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) stream_modes = normalize_stream_modes(body.stream_mode)
+6 -15
View File
@@ -33,16 +33,6 @@ concurrency:
fail_open: true fail_open: true
models: 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
supports_thinking: true
- name: MiniMax-M2.7-fash - name: MiniMax-M2.7-fash
display_name: MiniMax-M2.7-fash display_name: MiniMax-M2.7-fash
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -177,7 +167,7 @@ models:
# display_name: MiniMax-M2.7 # display_name: MiniMax-M2.7
# use: langchain_openai:ChatOpenAI # use: langchain_openai:ChatOpenAI
# model: MiniMax-M2.7 # model: MiniMax-M2.7
# api_key: sk-cp-mXNcPkl81WAERsVaVOCj5r-F3LQv8kdtMWzx_U4RNAJejJKtK_JB2mHLFqBkBYvVDb8OPZmxJgiuctDHhDImvkHvJqGj_RvvlTaCzFvhwIvuHT8jm46niBY # api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
# base_url: https://api.minimaxi.com/v1 # base_url: https://api.minimaxi.com/v1
# max_tokens: 4096 # max_tokens: 4096
# temperature: 1.0 # temperature: 1.0
@@ -474,10 +464,11 @@ checkpointer:
type: sqlite type: sqlite
connection_string: .deer-flow/data/deerflow.db connection_string: .deer-flow/data/deerflow.db
# 记忆子系统参数。真正的问答总开关由管理员「设置 → 问答记忆」控制, # 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# system_settings.json 没有显式开启时始终关闭;这里的 enabled 不会绕过管理员总开关。 # 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
memory: memory:
enabled: false enabled: true
version: version:
v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取) v1 # 记忆版本: 'v1'(原版 DeerFlow, memory.json + LLM 自动提取)
# 'v2'(USER.md/MEMORY.md + 可选 Hindsight) # 'v2'(USER.md/MEMORY.md + 可选 Hindsight)
@@ -606,7 +597,7 @@ llmwiki:
enabled: true enabled: true
# 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。 # 免登录 POST /api/knowledge/vector-search 返回的 Wiki 一键跳转链接前缀。
frontend_base_url: "http://127.0.0.1:5174" frontend_base_url: "http://127.0.0.1:5174"
auto_sync: true auto_sync: false
sync_interval_seconds: 30 sync_interval_seconds: 30
database_batch_size: 50 database_batch_size: 50
embedding: embedding:
@@ -19,7 +19,7 @@ The summarization feature uses DeerFlow's `DeerFlowSummarizationMiddleware` (a s
LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this: LangChain's stock `SummarizationMiddleware` runs in `before_model` and persists a `RemoveMessage(REMOVE_ALL_MESSAGES)` to the checkpoint — which permanently deletes the original Q&A from both the model context *and* the conversation the frontend renders. DeerFlow overrides this:
- `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled). - `before_model` / `abefore_model` are **no-ops** (the destructive path is disabled).
- Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the hidden summary is merged into LangChain's dedicated `ModelRequest.system_message` slot and the ordinary message list is replaced with the recent tail. This guarantees exactly one leading system message even for strict Qwen-style or generic `langchain_chat` adapters. **Nothing is written back to state.** - Compression runs in `wrap_model_call` / `awrap_model_call`: for a single model call, the message list is replaced with `[summary, *recent]` via `request.override(messages=...)`. **Nothing is written back to state.**
- The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all. - The full original messages stay in the checkpoint, so the frontend keeps showing the user's real conversation. The summary message is named `summary` and flagged `hide_from_ui`, and because it only ever lives in the transient request it never reaches the frontend at all.
- The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first). - The generated summary is cached **per-thread** so we don't pay an extra LLM call on every model invocation. The cached summary is reused (no LLM call) until enough *new* messages accumulate to push the compressed context back over the trigger threshold (a sticky boundary mirroring the stock middleware's post-compaction behavior). The cache is bounded (oldest threads evicted first).
- **Threshold-first**: while the full current conversation is still under the trigger, the request is passed through untouched — no cache lookup, no summary substitution. Compression only ever engages once the real context crosses the configured trigger. - **Threshold-first**: while the full current conversation is still under the trigger, the request is passed through untouched — no cache lookup, no summary substitution. Compression only ever engages once the real context crosses the configured trigger.
@@ -185,7 +185,7 @@ The default LangChain prompt instructs the model to:
- Messages to summarize (older messages beyond the `keep` threshold) - Messages to summarize (older messages beyond the `keep` threshold)
- Messages to preserve (recent messages within the `keep` threshold) - Messages to preserve (recent messages within the `keep` threshold)
4. **Summary Generation**: The model generates a concise summary of the older messages 4. **Summary Generation**: The model generates a concise summary of the older messages
5. **Context Replacement (transient)**: The request **handed to the model for this call** is rebuilt with one system message (agent prompt + hidden summary) and the recent non-system tail. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation. 5. **Context Replacement (transient)**: The message list **handed to the model for this call** is rebuilt as `[summary, *recent]`. The persisted state is left untouched — old messages are **not** removed, so the frontend still renders the full original conversation.
6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together 6. **AI/Tool Pair Protection**: The system ensures AI messages and their corresponding tool messages stay together
7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact. 7. **Skill Rescue**: Before the summary is generated, the most recently loaded skill files (tool results whose tool name is in `skill_file_read_tool_names` and whose target path is under `skills.container_path`) are lifted out of the summarization set and prepended to the preserved tail. Selection walks newest-first under three budgets: `preserve_recent_skill_count`, `preserve_recent_skill_tokens`, and `preserve_recent_skill_tokens_per_skill`. The triggering AIMessage and all of its paired ToolMessages move together so tool_call ↔ tool_result pairing stays intact.
@@ -202,14 +202,12 @@ The middleware intelligently preserves message context:
- **Recent Messages**: Always kept intact based on `keep` configuration - **Recent Messages**: Always kept intact based on `keep` configuration
- **AI/Tool Pairs**: Never split - if a cutoff point falls within tool messages, the system adjusts to keep the entire AI + Tool message sequence together - **AI/Tool Pairs**: Never split - if a cutoff point falls within tool messages, the system adjusts to keep the entire AI + Tool message sequence together
- **Summary Format**: Summary is merged into the leading SystemMessage with the format: - **Summary Format**: Summary is injected as a HumanMessage with the format:
``` ```
Internal conversation summary for continuity only... Here is a summary of the conversation to date:
<internal_context_summary> [Generated summary text]
[generated summary text] ```
</internal_context_summary>
```
## Best Practices ## Best Practices
@@ -331,8 +329,7 @@ Summarization runs after ThreadData and Sandbox initialization but before Title
- Configuration is loaded once at startup - Configuration is loaded once at startup
- Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation - Summarization is **non-destructive**: the summary replaces older messages only in the transient model request (`wrap_model_call`), never in persisted state. The checkpointer therefore keeps the full original conversation
- The per-thread summary cache lives in a **process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded), **not** on the middleware instance. `make_lead_agent` rebuilds a fresh middleware every run, so an instance cache would be cold each turn and re-run a blocking summary LLM call on every turn of a long conversation; the module-level cache keyed by `thread_id` lets a summary survive across turns. It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct. - The per-thread summary cache lives in a **process-wide, thread-safe LRU** (`_GLOBAL_SUMMARY_CACHE`, bounded), **not** on the middleware instance. `make_lead_agent` rebuilds a fresh middleware every run, so an instance cache would be cold each turn and re-run a blocking summary LLM call on every turn of a long conversation; the module-level cache keyed by `thread_id` lets a summary survive across turns. It is a cost optimization only — a cold/stale cache simply re-summarizes, which is always correct
- Compression normalizes LangChain's split request representation before invoking the next middleware/model: the dedicated `system_message` contains all system context exactly once, while `messages` contains no `SystemMessage`. This makes the fix independent of the configured provider adapter.
## Example Configurations ## Example Configurations
@@ -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], messages: list[Any],
*, *,
query: str, query: str,
include_rag_sources: bool = False,
) -> tuple[list[ResearchMaterial], str]: ) -> tuple[list[ResearchMaterial], str]:
"""Convert an in-memory message list into research materials. """Convert an in-memory message list into research materials.
@@ -142,16 +141,6 @@ def harvest_messages_materials(
the thread produced no harvestable tool results. the thread produced no harvestable tool results.
""" """
materials = _harvest_tool_messages(messages, query=query) 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) agent_summary = _last_assistant_text(messages)
if not materials and agent_summary.strip(): if not materials and agent_summary.strip():
# Weak-model fallback: the agent narrated its findings but never called # Weak-model fallback: the agent narrated its findings but never called
@@ -29,8 +29,7 @@ from deerflow.agents.middlewares.tool_error_handling_middleware import build_lea
from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddleware
from deerflow.agents.thread_state import ThreadState 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.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.app_config import AppConfig, get_app_config
from deerflow.config.system_settings import get_memory_runtime_settings
from deerflow.models import create_chat_model from deerflow.models import create_chat_model
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -434,14 +433,8 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs # Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow) and ephemeral runs
memory_injection_enabled = cfg.get("memory_injection_enabled", True) memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if ( if not is_bootstrap and not is_writing_setup and not is_report_structure_setup and not is_ephemeral:
get_memory_runtime_settings().enabled middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_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))
# KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated # KnowledgeRagMiddleware — inject knowledge-base recall (phase 2). Gated
# per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting # per-conversation by the ``knowledge_rag_enabled`` runtime flag, defaulting
@@ -861,10 +854,6 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
system_prompt = _append_orchestration_capability_boundary(system_prompt, agent_id) 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( _log_es_query_routing_registration(
system_prompt=system_prompt, system_prompt=system_prompt,
app_config=resolved_app_config, app_config=resolved_app_config,
@@ -864,7 +864,7 @@ RAG/reference display mode is enabled.
return prompt return prompt
def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str: def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig | None = None, injection_enabled: bool = True) -> str:
"""组装注入系统提示词的记忆上下文 (Memory V2)。 """组装注入系统提示词的记忆上下文 (Memory V2)。
通过 :class:`MemoryManager` 收集各 provider 的记忆块。P1 期只有 builtin 通过 :class:`MemoryManager` 收集各 provider 的记忆块。P1 期只有 builtin
@@ -879,17 +879,15 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
Returns: Returns:
包在 ``<memory>`` 标签里的记忆上下文;未启用或为空时返回空串。 包在 ``<memory>`` 标签里的记忆上下文;未启用或为空时返回空串。
""" """
if not injection_enabled: if not injection_enabled:
return "" return ""
try: try:
from deerflow.agents.memory.manager import build_memory_manager from deerflow.agents.memory.manager import build_memory_manager
from deerflow.config.memory_config import get_effective_memory_config from deerflow.config.memory_config import get_effective_memory_config
from deerflow.runtime.user_context import get_effective_user_id from deerflow.runtime.user_context import get_effective_user_id
user_id = get_effective_user_id() user_id = get_effective_user_id()
config = get_effective_memory_config(user_id) config = get_effective_memory_config(user_id)
if not config.enabled:
return ""
manager = build_memory_manager( manager = build_memory_manager(
user_id=user_id, user_id=user_id,
@@ -18,9 +18,8 @@ def _get_v1_cfg():
def _memory_enabled() -> bool: def _memory_enabled() -> bool:
from deerflow.config.system_settings import get_memory_runtime_settings from deerflow.config.memory_config import get_memory_config
return get_memory_config().enabled
return get_memory_runtime_settings().enabled
@dataclass @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.message_processing import detect_correction, detect_reinforcement, filter_messages_for_memory
from deerflow.agents.memory.v1.queue import get_memory_queue 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: def memory_flush_hook(event: object) -> None:
@@ -16,7 +16,7 @@ def memory_flush_hook(event: object) -> None:
- ``agent_name: str | None`` - ``agent_name: str | None``
- ``runtime: RunnableConfig`` - ``runtime: RunnableConfig``
""" """
if not get_memory_runtime_settings().enabled: if not get_memory_config().enabled:
return return
thread_id = getattr(event, "thread_id", None) thread_id = getattr(event, "thread_id", None)
@@ -260,9 +260,8 @@ class MemoryUpdater:
reinforcement_detected: bool, reinforcement_detected: bool,
user_id: str | None = None, user_id: str | None = None,
) -> tuple[dict[str, Any], str] | None: ) -> tuple[dict[str, Any], str] | None:
from deerflow.config.memory_config import get_effective_memory_config from deerflow.config.memory_config import get_memory_config
config = get_memory_config()
config = get_effective_memory_config(user_id or "default")
if not config.enabled or not messages: if not config.enabled or not messages:
return None return None
@@ -18,7 +18,7 @@ LLM 主动写入走 ``memory`` / ``hindsight_*`` 工具,不经过本中间件。
import asyncio import asyncio
import logging import logging
from typing import Any, override from typing import TYPE_CHECKING, Any, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware 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.manager import build_memory_manager
from deerflow.agents.memory.message_processing import count_user_turns, filter_messages_for_memory 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 from deerflow.runtime.user_context import get_effective_user_id
if TYPE_CHECKING:
from deerflow.config.memory_config import MemoryConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -87,15 +90,17 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
state_schema = 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。 """初始化 MemoryMiddleware。
Args: Args:
agent_name: 提供时记忆按 agent 隔离;None 用默认 agent。 agent_name: 提供时记忆按 agent 隔离;None 用默认 agent。
memory_config: 显式记忆配置;省略时用全局配置。
injection_enabled: 运行时开关;``False`` 时跳过 Hindsight 动态召回注入。 injection_enabled: 运行时开关;``False`` 时跳过 Hindsight 动态召回注入。
""" """
super().__init__() super().__init__()
self._agent_name = agent_name self._agent_name = agent_name
self._memory_config = memory_config
self._injection_enabled = injection_enabled self._injection_enabled = injection_enabled
# ---- 运行时上下文解析 ------------------------------------------------- # ---- 运行时上下文解析 -------------------------------------------------
@@ -145,6 +150,10 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
return None return None
if not self._injection_enabled: if not self._injection_enabled:
return None return None
config = self._memory_config or get_memory_config()
if not config.enabled:
return None
user_id = get_effective_user_id() user_id = get_effective_user_id()
effective_config = get_effective_memory_config(user_id) effective_config = get_effective_memory_config(user_id)
if not effective_config.enabled or not effective_config.injection.enabled: if not effective_config.enabled or not effective_config.injection.enabled:
@@ -212,13 +221,13 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]):
Returns: Returns:
None (本中间件不改状态)。 None (本中间件不改状态)。
""" """
config = self._memory_config or get_memory_config()
if not config.enabled:
return None
user_id = get_effective_user_id() user_id = get_effective_user_id()
effective_config = get_effective_memory_config(user_id) effective_config = get_effective_memory_config(user_id)
if ( if effective_config.provider != "hindsight" or not effective_config.hindsight.auto_retain:
not effective_config.enabled
or effective_config.provider != "hindsight"
or not effective_config.hindsight.auto_retain
):
return None return None
messages = state.get("messages", []) messages = state.get("messages", [])
@@ -5,9 +5,8 @@ Unlike LangChain's stock ``SummarizationMiddleware`` (which runs in
to the checkpoint — wiping the original Q&A from both the model context *and* to the checkpoint — wiping the original Q&A from both the model context *and*
the conversation the frontend renders), this middleware compresses the history the conversation the frontend renders), this middleware compresses the history
**transiently in ``wrap_model_call``**. The full original messages stay in state **transiently in ``wrap_model_call``**. The full original messages stay in state
(so the UI keeps showing the user's real conversation); only the request handed (so the UI keeps showing the user's real conversation); only the message list
to the model is replaced with a merged leading system context plus recent handed to the model for a single call is replaced with ``[summary, *recent]``.
messages.
The generated summary is cached per-thread so we don't pay an extra LLM call on The generated summary is cached per-thread so we don't pay an extra LLM call on
every model invocation: once a thread is summarized, the cached summary is reused every model invocation: once a thread is summarized, the cached summary is reused
@@ -40,8 +39,6 @@ from langchain_core.messages import (
from langgraph.config import get_config from langgraph.config import get_config
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
from deerflow.models.message_ordering import normalize_model_request_messages
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Upper bound on the per-thread summary cache so a long-lived process serving # Upper bound on the per-thread summary cache so a long-lived process serving
@@ -324,14 +321,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> ModelCallResult: ) -> ModelCallResult:
compressed = self._compressed_messages(request) compressed = self._compressed_messages(request)
if compressed is not None: if compressed is not None:
system_message, messages = normalize_model_request_messages( request = request.override(messages=compressed)
request.system_message,
compressed,
)
request = request.override(
system_message=system_message,
messages=messages,
)
return handler(request) return handler(request)
@override @override
@@ -342,14 +332,7 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
) -> ModelCallResult: ) -> ModelCallResult:
compressed = await self._acompressed_messages(request) compressed = await self._acompressed_messages(request)
if compressed is not None: if compressed is not None:
system_message, messages = normalize_model_request_messages( request = request.override(messages=compressed)
request.system_message,
compressed,
)
request = request.override(
system_message=system_message,
messages=messages,
)
return await handler(request) return await handler(request)
def _compressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None: def _compressed_messages(self, request: ModelRequest) -> list[AnyMessage] | None:
@@ -77,7 +77,6 @@ class LocalWikiEmbeddingConfig(BaseModel):
base_url: str = "" base_url: str = ""
api_key: str = Field(default="", repr=False) api_key: str = Field(default="", repr=False)
local_model_path: str = "" local_model_path: str = ""
local_model_revision: str = ""
local_cache_dir: str = "" local_cache_dir: str = ""
local_process_isolation: bool = True local_process_isolation: bool = True
local_threads: int = Field(default=2, ge=1, le=32) local_threads: int = Field(default=2, ge=1, le=32)
@@ -12,11 +12,8 @@
injection: {...} # 系统提示词注入 injection: {...} # 系统提示词注入
security: {...} # 内容安全扫描 security: {...} # 内容安全扫描
运行时总开关与配置覆盖 两层配置覆盖
====================== ============
管理员维护的 ``system_settings.json -> memory.enabled`` 是权威总开关,默认
``false``。关闭时下面所有 provider 参数都不生效。
``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低): ``get_effective_memory_config(user_id)`` 按优先级合并 (高 -> 低):
1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取) 1. per-user 覆盖 数据库 ``user_memory_configs`` 表 (经进程内 cache 读取)
(有 per-user 记录时自动强制 enabled=True) (有 per-user 记录时自动强制 enabled=True)
@@ -35,8 +32,6 @@ from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from deerflow.config.system_settings import get_memory_runtime_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -131,7 +126,7 @@ class V1MemoryConfig(BaseModel):
class MemoryConfig(BaseModel): class MemoryConfig(BaseModel):
"""记忆子系统的全局配置 (config.yaml 的 ``memory:`` 段)。""" """记忆子系统的全局配置 (config.yaml 的 ``memory:`` 段)。"""
enabled: bool = Field(default=False, description="记忆子系统总开关") enabled: bool = Field(default=True, description="记忆子系统总开关")
version: str = Field( version: str = Field(
default="v2", default="v2",
description="记忆系统版本:v1(原版 DeerFlow,memory.json + LLM 自动提取) 或 v2(USER.md/MEMORY.md)", description="记忆系统版本:v1(原版 DeerFlow,memory.json + LLM 自动提取) 或 v2(USER.md/MEMORY.md)",
@@ -258,18 +253,10 @@ def get_recall_budget_from_cache(user_id: str) -> str | None:
def get_effective_memory_config(user_id: str) -> MemoryConfig: def get_effective_memory_config(user_id: str) -> MemoryConfig:
"""返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。 """返回某用户的生效记忆配置 (同步,从进程内 cache 读取 per-user 覆盖)。
管理员运行时总开关关闭时直接返回 ``enabled=False``;开启后再按 优先级 (高 -> 低):per-user DB 覆盖 (经 cache) > config.yaml > 默认值。
per-user DB 覆盖 (经 cache) > config.yaml > 默认值 合并参数。 有 per-user 记录时自动强制 enabled=True。
""" """
# 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 = _memory_config.model_dump()
base["enabled"] = True
recall_budget = _recall_budget_cache.get(user_id) recall_budget = _recall_budget_cache.get(user_id)
if recall_budget is not None: if recall_budget is not None:
base.setdefault("hindsight", {})["recall_budget"] = recall_budget base.setdefault("hindsight", {})["recall_budget"] = recall_budget
@@ -505,41 +505,14 @@ class ModelConcurrencySettings(BaseModel):
model_config = ConfigDict(extra="ignore") 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): class LocalWikiEncoderSettings(BaseModel):
"""Runtime override for the administrator-managed offline Wiki encoder.""" """Runtime override for the administrator-managed offline Wiki encoder."""
mode: Literal["configured", "local"] = Field(
default="configured",
description=(
"Use the embedding provider from config.yaml, or explicitly override it "
"with the administrator-managed offline BGE-M3 encoder."
),
)
model_path: str = Field( model_path: str = Field(
default="", default="",
max_length=4096, max_length=4096,
description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.", description="Absolute or project-relative directory containing the offline BGE-M3 ONNX model.",
) )
model_revision: str = Field(
default="",
max_length=64,
description="Opaque revision changed whenever an offline model directory is activated.",
)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -568,7 +541,6 @@ class SystemSettings(BaseModel):
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings) agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings) concurrency_monitor: ConcurrencyMonitorSettings = Field(default_factory=ConcurrencyMonitorSettings)
model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings) model_concurrency: ModelConcurrencySettings = Field(default_factory=ModelConcurrencySettings)
memory: MemoryRuntimeSettings = Field(default_factory=MemoryRuntimeSettings)
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings) local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings) strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride) llmwiki: LlmWikiRuntimeOverride = Field(default_factory=LlmWikiRuntimeOverride)
@@ -630,26 +602,12 @@ def get_model_concurrency_settings() -> ModelConcurrencySettings:
return 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: def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value.""" """Persist the settings document atomically, returning the saved value."""
path = _settings_path() path = _settings_path()
payload = settings.model_dump(mode="json") payload = settings.model_dump(mode="json")
with _lock: with _lock:
# Multiple Gateway workers share this file. A unique temporary name tmp = path.with_suffix(path.suffix + ".tmp")
# prevents their atomic replaces from racing over one common `.tmp`. tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp = path.with_name(f".{path.name}.{uuid4().hex}.tmp") tmp.replace(path)
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
return settings return settings
@@ -57,9 +57,6 @@ class StrictWikiEmbeddingClient:
self._local_started_at: str | None = None self._local_started_at: str | None = None
self._local_ready_at: str | None = None self._local_ready_at: str | None = None
self._local_warmup_task: asyncio.Task[None] | None = None self._local_warmup_task: asyncio.Task[None] | None = None
self._lifecycle_condition = asyncio.Condition()
self._active_uses = 0
self._retired = False
@property @property
def dimensions(self) -> int | None: def dimensions(self) -> int | None:
@@ -67,21 +64,7 @@ class StrictWikiEmbeddingClient:
@property @property
def fingerprint(self) -> str: def fingerprint(self) -> str:
parts = [ raw = "\0".join((self.config.provider.strip().lower(), self.config.base_url.rstrip("/").lower(), self.config.model.strip(), str(self._resolved_dimensions or "auto")))
self.config.provider.strip().lower(),
self.config.base_url.rstrip("/").lower(),
self.config.model.strip(),
str(self._resolved_dimensions or "auto"),
]
if self.config.provider == "local":
model_path = self._local_model_path()
parts.extend(
(
str(model_path) if model_path is not None else "",
self.config.local_model_revision,
)
)
raw = "\0".join(parts)
return hashlib.sha256(raw.encode("utf-8")).hexdigest() return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def close(self) -> None: async def close(self) -> None:
@@ -90,15 +73,6 @@ class StrictWikiEmbeddingClient:
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None
async def retire(self) -> None:
"""Reject future work, drain active calls, then release model resources."""
async with self._lifecycle_condition:
self._retired = True
async with self._lifecycle_condition:
await self._lifecycle_condition.wait_for(lambda: self._active_uses == 0)
await self.close()
async def reset_local_encoder(self, *, model_path: str | None = None) -> None: async def reset_local_encoder(self, *, model_path: str | None = None) -> None:
"""Stop only the embedded worker and optionally switch its model path. """Stop only the embedded worker and optionally switch its model path.
@@ -108,80 +82,27 @@ class StrictWikiEmbeddingClient:
""" """
warmup = self._local_warmup_task warmup = self._local_warmup_task
if warmup is not None and not warmup.done(): if warmup is not None and not warmup.done():
# Local inference runs in a worker thread. Cancelling the asyncio warmup.cancel()
# wrapper does not stop that thread and could let it publish a new
# process/model after cleanup. Drain it before touching resources.
await asyncio.gather(warmup, return_exceptions=True) await asyncio.gather(warmup, return_exceptions=True)
self._local_warmup_task = None self._local_warmup_task = None
# Wait for a non-warmup embedding batch to finish before replacing or process = self._local_process
# stopping the local worker. This prevents an admin upload from self._local_process = None
# breaking a concurrent vectorization request halfway through IPC. if process is not None:
async with self._local_lock: await asyncio.to_thread(self._stop_local_process, process)
process = self._local_process self._local_model = None
self._local_process = None self._local_state = "idle"
if process is not None: self._local_error = None
await self._run_thread_drained(self._stop_local_process, process) self._local_started_at = None
self._local_model = None self._local_ready_at = None
self._local_state = "idle" if model_path is not None:
self._local_error = None self.config = self.config.model_copy(update={"local_model_path": model_path})
self._local_started_at = None
self._local_ready_at = None
if model_path is not None:
self.config = self.config.model_copy(update={"local_model_path": model_path})
async def _get_client(self) -> httpx.AsyncClient: async def _get_client(self) -> httpx.AsyncClient:
if self._client is None: if self._client is None:
self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds) self._client = httpx.AsyncClient(timeout=self.config.timeout_seconds)
return self._client return self._client
@staticmethod
async def _run_thread_drained(function, *args):
"""Do not release lifecycle locks while cancelled thread work still runs."""
worker = asyncio.create_task(asyncio.to_thread(function, *args))
cancelled = False
worker_error: BaseException | None = None
result = None
while not worker.done():
try:
result = await asyncio.shield(worker)
except asyncio.CancelledError:
# A caller can be cancelled more than once during shutdown.
# Swallow every cancellation until the non-cancellable thread
# has really stopped, then restore cancellation semantics.
cancelled = True
except BaseException as exc:
worker_error = exc
break
if worker.done() and worker_error is None:
try:
result = worker.result()
except BaseException as exc:
worker_error = exc
if cancelled:
raise asyncio.CancelledError from None
if worker_error is not None:
raise worker_error
return result
async def embed_texts(self, texts: list[str]) -> list[list[float]]: async def embed_texts(self, texts: list[str]) -> list[list[float]]:
async with self._lifecycle_condition:
if self._retired:
raise WikiEmbeddingError(
"Wiki embedding runtime was replaced; retry with the active provider",
category="runtime_retired",
retryable=True,
)
self._active_uses += 1
try:
return await self._embed_texts_active(texts)
finally:
async with self._lifecycle_condition:
self._active_uses -= 1
if self._active_uses == 0:
self._lifecycle_condition.notify_all()
async def _embed_texts_active(self, texts: list[str]) -> list[list[float]]:
if self.config.provider == "local": if self.config.provider == "local":
if not texts: if not texts:
return [] return []
@@ -189,10 +110,7 @@ class StrictWikiEmbeddingClient:
self._local_state = "starting" self._local_state = "starting"
self._local_started_at = self._local_started_at or datetime.now(UTC).isoformat() self._local_started_at = self._local_started_at or datetime.now(UTC).isoformat()
try: try:
# `to_thread` keeps running after its awaiting task is values = await asyncio.to_thread(self._embed_local, texts)
# cancelled. Keep the lifecycle lease and local lock until
# the real inference call has stopped.
values = await self._run_thread_drained(self._embed_local, texts)
except Exception as exc: except Exception as exc:
self._local_state = "failed" self._local_state = "failed"
self._local_error = " ".join(str(exc).split())[:1000] or type(exc).__name__ self._local_error = " ".join(str(exc).split())[:1000] or type(exc).__name__
@@ -534,8 +452,6 @@ class StrictWikiEmbeddingClient:
def start_local_encoder(self) -> dict[str, object]: def start_local_encoder(self) -> dict[str, object]:
"""Warm the embedded CPU encoder without coupling it to app startup.""" """Warm the embedded CPU encoder without coupling it to app startup."""
if self._retired:
raise ValueError("当前本地编码器运行时已切换,请刷新状态后重试")
if self.config.provider != "local": if self.config.provider != "local":
raise ValueError("当前 embedding provider 不是 local") raise ValueError("当前 embedding provider 不是 local")
if not self.config.enabled: if not self.config.enabled:
@@ -11,7 +11,7 @@ from uuid import uuid4
from deerflow.config.llmwiki_config import LocalWikiIndexConfig from deerflow.config.llmwiki_config import LocalWikiIndexConfig
from deerflow.integrations.weknora.client import WeKnoraClient from deerflow.integrations.weknora.client import WeKnoraClient
from deerflow.integrations.weknora.local_index.chunking import chunk_wiki_page from deerflow.integrations.weknora.local_index.chunking import chunk_wiki_page
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient
from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page
from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache
from deerflow.integrations.weknora.local_index.vector_codec import encode_vector from deerflow.integrations.weknora.local_index.vector_codec import encode_vector
@@ -137,8 +137,6 @@ class WikiSyncService:
except Exception as exc: except Exception as exc:
if isinstance(exc, WikiSyncLeaseLost): if isinstance(exc, WikiSyncLeaseLost):
raise raise
if isinstance(exc, WikiEmbeddingError) and exc.category == "runtime_retired":
raise
failed += 1 failed += 1
try: try:
fallback = normalize_wiki_page(mapping_id, summary) fallback = normalize_wiki_page(mapping_id, summary)
@@ -186,26 +186,11 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
if "stream_usage" in getattr(model_class, "model_fields", {}): if "stream_usage" in getattr(model_class, "model_fields", {}):
model_settings_from_config["stream_usage"] = True model_settings_from_config["stream_usage"] = True
model_instance = model_class(**kwargs, **model_settings_from_config) model_instance = model_class(**kwargs, **model_settings_from_config)
# Always emit a compact model lifecycle trace. In particular, the first callbacks = build_tracing_callbacks()
# streamed callback proves that the upstream endpoint accepted the request if callbacks:
# and started returning data, including reasoning-only chunks whose normal existing_callbacks = model_instance.callbacks or []
# 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 []
model_instance.callbacks = [*existing_callbacks, *callbacks] model_instance.callbacks = [*existing_callbacks, *callbacks]
logger.debug(f"Tracing attached to model '{name}' with providers={len(callbacks)}") logger.debug(f"Tracing attached to model '{name}' with providers={len(callbacks)}")
return model_instance return model_instance
@@ -6,6 +6,7 @@ from typing import Any
from langchain_core.messages import BaseMessage, SystemMessage from langchain_core.messages import BaseMessage, SystemMessage
_SYSTEM_SEPARATOR = "\n\n---\n\n" _SYSTEM_SEPARATOR = "\n\n---\n\n"
@@ -34,11 +35,7 @@ def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessag
return messages return messages
first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages)) first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages))
# A number of strict chat templates accept *one* system message, and only if len(system_messages) == first_non_system_index:
# at index zero. Two adjacent leading system messages are therefore not a
# valid no-op case: LangChain's agent prompt followed by DeerFlow's
# transient compaction summary must still be folded into one message.
if len(system_messages) == 1 and first_non_system_index == 1:
return messages return messages
merged = system_messages[0].model_copy( merged = system_messages[0].model_copy(
@@ -49,38 +46,20 @@ def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessag
return [merged, *(message for message in messages if not isinstance(message, SystemMessage))] return [merged, *(message for message in messages if not isinstance(message, SystemMessage))]
def normalize_model_request_messages(
system_message: SystemMessage | None,
messages: list[BaseMessage],
) -> tuple[SystemMessage | None, list[BaseMessage]]:
"""Normalize LangChain's split ``ModelRequest`` message representation.
``ModelRequest.messages`` is documented as excluding the system prompt.
Transient middleware (notably context compaction) can nevertheless place a
``SystemMessage`` in that list. Fold every such message into the dedicated
system slot so adapters without a DeerFlow-specific payload patch also
receive exactly one leading system message.
"""
if system_message is None and not any(isinstance(message, SystemMessage) for message in messages):
return None, messages
combined = [system_message, *messages] if system_message is not None else messages
normalized = normalize_langchain_messages(combined)
if normalized and isinstance(normalized[0], SystemMessage):
return normalized[0], normalized[1:]
return None, normalized
def _normalize_payload_message_list(messages: list[Any]) -> list[Any]: def _normalize_payload_message_list(messages: list[Any]) -> list[Any]:
system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"] system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"]
if not system_messages: if not system_messages:
return messages return messages
first_non_system_index = next( first_non_system_index = next(
(idx for idx, message in enumerate(messages) if not (isinstance(message, dict) and message.get("role") == "system")), (
idx
for idx, message in enumerate(messages)
if not (isinstance(message, dict) and message.get("role") == "system")
),
len(messages), len(messages),
) )
if len(system_messages) == 1 and first_non_system_index == 1: if len(system_messages) == first_non_system_index:
return messages return messages
merged_system = dict(system_messages[0]) merged_system = dict(system_messages[0])
@@ -19,9 +19,8 @@ re-inject thinking metadata back into the outgoing payload for any assistant
message that originally carried it: message that originally carried it:
- ``thought_signature`` for Gemini-compatible tool calls. - ``thought_signature`` for Gemini-compatible tool calls.
- ``reasoning_content`` and the newer ``reasoning`` spelling used by vLLM and - ``reasoning_content`` for providers (for example DeepSeek) that require it
other OpenAI-compatible providers. Both are normalized to to be echoed in multi-turn requests when thinking mode is enabled.
``additional_kwargs.reasoning_content`` for DeerFlow's existing clients.
""" """
from __future__ import annotations from __future__ import annotations
@@ -167,7 +166,7 @@ class PatchedChatOpenAI(ChatOpenAI):
default_chunk_class: type, default_chunk_class: type,
base_generation_info: dict | None, base_generation_info: dict | None,
) -> ChatGenerationChunk | 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 # Some compatible gateways return a vendor error event followed by a
# normal-looking usage chunk, all under HTTP 200. Detect the first # normal-looking usage chunk, all under HTTP 200. Detect the first
# event before LangChain converts it into an empty AIMessageChunk. # event before LangChain converts it into an empty AIMessageChunk.
@@ -175,8 +174,8 @@ class PatchedChatOpenAI(ChatOpenAI):
generation_chunk = super()._convert_chunk_to_generation_chunk( generation_chunk = super()._convert_chunk_to_generation_chunk(
chunk, chunk,
default_chunk_class, default_chunk_class,
base_generation_info, base_generation_info,
) )
if generation_chunk is None: if generation_chunk is None:
return None return None
@@ -184,12 +183,11 @@ class PatchedChatOpenAI(ChatOpenAI):
choice = choices[0] if choices else {} choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None) reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk): if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = _append_reasoning_content( generation_chunk.message = _append_reasoning_content(
generation_chunk.message, generation_chunk.message,
reasoning_content, reasoning_content,
reasoning=delta.get("reasoning") if isinstance(delta, dict) else None, )
)
return generation_chunk return generation_chunk
def _create_chat_result( def _create_chat_result(
@@ -197,24 +195,20 @@ class PatchedChatOpenAI(ChatOpenAI):
response: dict | Any, response: dict | Any,
generation_info: dict | None = None, generation_info: dict | None = None,
) -> ChatResult: ) -> 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() response_dict = response if isinstance(response, dict) else response.model_dump()
_raise_for_application_error(response_dict) _raise_for_application_error(response_dict)
result = super()._create_chat_result(response, generation_info) result = super()._create_chat_result(response, generation_info)
choices = response_dict.get("choices", []) choices = response_dict.get("choices", [])
generations: list[ChatGeneration] = [] generations: list[ChatGeneration] = []
for index, generation in enumerate(result.generations): for index, generation in enumerate(result.generations):
message = generation.message message = generation.message
choice = choices[index] if index < len(choices) else {} choice = choices[index] if index < len(choices) else {}
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {} choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None) reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None)
if reasoning_content and isinstance(message, AIMessage): if reasoning_content and isinstance(message, AIMessage):
message = _append_reasoning_content( message = _append_reasoning_content(message, reasoning_content)
message,
reasoning_content,
reasoning=choice_message.get("reasoning") if isinstance(choice_message, dict) else None,
)
generation = ChatGeneration( generation = ChatGeneration(
message=message, message=message,
generation_info=generation.generation_info, generation_info=generation.generation_info,
@@ -265,46 +259,34 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
payload_tc["thought_signature"] = sig payload_tc["thought_signature"] = sig
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None: def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject provider reasoning onto an assistant history message. """Re-inject ``reasoning_content`` onto assistant payload message.
Some thinking-enabled OpenAI-compatible providers require each historical Some thinking-enabled OpenAI-compatible providers require each historical
assistant turn to include its original reasoning in subsequent requests. assistant turn to include the original ``reasoning_content`` in subsequent
Preserve the spelling returned by the provider when available; otherwise requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does
retain the established ``reasoning_content`` behavior. not always serialize it back into ``messages``.
""" """
reasoning = orig_msg.additional_kwargs.get("reasoning") reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning is not None: if reasoning_content is not None:
payload_msg["reasoning"] = reasoning payload_msg["reasoning_content"] = reasoning_content
return
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content") def _extract_reasoning_content(payload: dict | None) -> str | None:
if reasoning_content is not None: """Extract reasoning text from provider payload delta/message objects."""
payload_msg["reasoning_content"] = reasoning_content if not isinstance(payload, dict):
return None
reasoning_content = payload.get("reasoning_content")
def _extract_reasoning_content(payload: dict | None) -> str | None: return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None
"""Extract either supported reasoning spelling from provider payloads."""
if not isinstance(payload, dict):
return None def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
reasoning_content = payload.get("reasoning_content") or payload.get("reasoning") """Append streamed reasoning chunks, dedupe complete response reasoning."""
return reasoning_content if isinstance(reasoning_content, str) and reasoning_content else None additional_kwargs = dict(message.additional_kwargs)
existing = additional_kwargs.get("reasoning_content")
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 isinstance(existing, str) and existing:
if reasoning_content not in existing: if reasoning_content not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}" additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
else: else:
additional_kwargs["reasoning_content"] = reasoning_content additional_kwargs["reasoning_content"] = reasoning_content
if reasoning is not None: return message.model_copy(update={"additional_kwargs": additional_kwargs})
additional_kwargs["reasoning"] = reasoning
return message.model_copy(update={"additional_kwargs": additional_kwargs})
@@ -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,
)
@@ -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, AgentRow,
AgentSkillRow, AgentSkillRow,
) )
from deerflow.persistence.analysis_projects import AnalysisProjectConfigRow, AnalysisProjectRow, AnalysisProjectThreadRow
from deerflow.persistence.assistant_knowledge.model import ( from deerflow.persistence.assistant_knowledge.model import (
AssistantKnowledgeBaseRow, AssistantKnowledgeBaseRow,
AssistantKnowledgeEntityContributionRow, AssistantKnowledgeEntityContributionRow,
@@ -151,7 +150,6 @@ from deerflow.persistence.workflow_runs.model import (
from deerflow.persistence.workflows.model import WorkflowDefinitionRow, WorkflowVersionRow from deerflow.persistence.workflows.model import WorkflowDefinitionRow, WorkflowVersionRow
__all__ = [ __all__ = [
"AnalysisProjectConfigRow",
"FeedbackRow", "FeedbackRow",
"FixedQuestionRow", "FixedQuestionRow",
"AgentExtrasRow", "AgentExtrasRow",
@@ -162,7 +160,6 @@ __all__ = [
"BusinessMappingRow", "BusinessMappingRow",
"AssistantKnowledgeBaseRow", "AssistantKnowledgeBaseRow",
"AssistantKnowledgeSourceRow", "AssistantKnowledgeSourceRow",
"AssistantKnowledgeVectorRow",
"AssistantKnowledgeImportJobRow", "AssistantKnowledgeImportJobRow",
"AssistantKnowledgeWikiPageRow", "AssistantKnowledgeWikiPageRow",
"AssistantKnowledgeWikiRevisionRow", "AssistantKnowledgeWikiRevisionRow",
@@ -262,8 +259,6 @@ __all__ = [
"TagAssignmentRow", "TagAssignmentRow",
"TagRow", "TagRow",
"TaskButtonRow", "TaskButtonRow",
"AnalysisProjectRow",
"AnalysisProjectThreadRow",
"TaskCopTaskRow", "TaskCopTaskRow",
"TaskReportRow", "TaskReportRow",
"EmbedSessionRow", "EmbedSessionRow",
@@ -180,8 +180,6 @@ def build_writing_research_config(
focus: str | None = None, focus: str | None = None,
thinking_enabled: bool = False, thinking_enabled: bool = False,
model_name: str | None = None, model_name: str | None = None,
project_mode: str | None = None,
project_report_config: dict | None = None,
) -> DeepResearchConfig: ) -> DeepResearchConfig:
"""组装聊天写作模式的研究配置:basic + 通用分析结构 + adaptive。 """组装聊天写作模式的研究配置:basic + 通用分析结构 + adaptive。
@@ -193,23 +191,12 @@ def build_writing_research_config(
parts: list[str] = [] parts: list[str] = []
if focus and focus.strip(): if focus and focus.strip():
parts.append(focus.strip()) parts.append(focus.strip())
from deerflow.agents.analysis_project import SOLUTION_OUTLINE parts.append(_KNOWLEDGE_WRITE_INSTRUCTION)
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
)
return DeepResearchConfig( return DeepResearchConfig(
mode="basic", mode="basic",
collection_mode="legacy", collection_mode="legacy",
custom_outline=outline if fixed_solution else GENERAL_OUTLINE, custom_outline=GENERAL_OUTLINE,
structure_mode=structure_mode, structure_mode="adaptive",
retrieval_directions=project_report_config.get("retrieval_directions", []) if fixed_solution else [],
retrieval_skills=project_report_config.get("retrieval_skills", []) if fixed_solution else [],
generate_images=False, generate_images=False,
thinking_enabled=thinking_enabled, thinking_enabled=thinking_enabled,
fast_model=model_name, fast_model=model_name,
@@ -447,9 +434,7 @@ async def deep_research_report_tool(
query = topic.strip() query = topic.strip()
state = getattr(runtime, "state", None) or {} state = getattr(runtime, "state", None) or {}
conversation_messages = state.get("messages") or [] conversation_messages = state.get("messages") or []
project_context = (runtime.context or {}).get("analysis_project") or {} harvested, _agent_summary = harvest_messages_materials(conversation_messages, query=query)
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)
# 课题请求之后的检索结果整批保留(那是模型为本课题检索的);只有课题 # 课题请求之后的检索结果整批保留(那是模型为本课题检索的);只有课题
# 切换前的旧检索才按词面强过滤(旧课题资料不得凑门槛、不得入报告)。 # 切换前的旧检索才按词面强过滤(旧课题资料不得凑门槛、不得入报告)。
anchor_index = topic_anchor_index(conversation_messages, topic=query) anchor_index = topic_anchor_index(conversation_messages, topic=query)
@@ -491,8 +476,6 @@ async def deep_research_report_tool(
focus=focus, focus=focus,
thinking_enabled=thinking_enabled, thinking_enabled=thinking_enabled,
model_name=model_name, model_name=model_name,
project_mode=project_context.get("mode"),
project_report_config=project_context.get("report_config"),
) )
llm_backend = DeerFlowCompletionBackend( llm_backend = DeerFlowCompletionBackend(
research_config, research_config,
@@ -135,14 +135,8 @@ def get_available_tools(
builtin_tools.append(scheduled_task_tool) builtin_tools.append(scheduled_task_tool)
# Memory V2:记忆与 builtin provider 都启用时,暴露 memory 工具给 LLM 主动写入 # Memory V2:记忆与 builtin provider 都启用时,暴露 memory 工具给 LLM 主动写入
memory_config = getattr(config, "memory", None) memory_config = getattr(config, "memory", None)
try: if memory_config is not None and getattr(memory_config, "enabled", False):
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:
builtin_config = getattr(memory_config, "builtin", None) builtin_config = getattr(memory_config, "builtin", None)
if builtin_config is not None and getattr(builtin_config, "enabled", False): if builtin_config is not None and getattr(builtin_config, "enabled", False):
builtin_tools.append(memory_tool) 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)
@@ -2,7 +2,7 @@ import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages, normalize_model_request_messages from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages
from deerflow.models.patched_openai import PatchedChatOpenAI from deerflow.models.patched_openai import PatchedChatOpenAI
@@ -49,37 +49,6 @@ def test_normalize_langchain_messages_moves_late_systems_before_history():
assert normalized[1].content == "user 0" assert normalized[1].content == "user 0"
def test_normalize_chat_payload_messages_merges_adjacent_leading_systems():
payload = {
"messages": [
{"role": "system", "content": "agent prompt"},
{"role": "system", "content": "compaction summary"},
{"role": "user", "content": "continue"},
]
}
normalize_chat_payload_messages(payload)
assert [message["role"] for message in payload["messages"]] == ["system", "user"]
assert payload["messages"][0]["content"] == "agent prompt\n\n---\n\ncompaction summary"
def test_normalize_model_request_messages_folds_compaction_summary_into_system_slot():
system_message, messages = normalize_model_request_messages(
SystemMessage(content="agent prompt"),
[
SystemMessage(content="compaction summary"),
HumanMessage(content="continue"),
],
)
assert system_message is not None
assert system_message.content == "agent prompt\n\n---\n\ncompaction summary"
assert len(messages) == 1
assert isinstance(messages[0], HumanMessage)
assert all(not isinstance(message, SystemMessage) for message in messages)
@pytest.mark.parametrize("turns", [12, 25, 50]) @pytest.mark.parametrize("turns", [12, 25, 50])
def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns): def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns):
model = PatchedChatOpenAI( model = PatchedChatOpenAI(
@@ -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)
@@ -1,283 +0,0 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Event
from types import SimpleNamespace
import pytest
import yaml
from fastapi import FastAPI
from starlette.requests import Request
from app.gateway import llmwiki_index_runtime
from app.gateway.routers import llmwiki_index
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig, LocalWikiIndexConfig
from deerflow.config.system_settings import LocalWikiEncoderSettings, SystemSettings
from deerflow.integrations.weknora.local_index.embedding import StrictWikiEmbeddingClient, WikiEmbeddingError
def _source_config(embedding: LocalWikiEmbeddingConfig):
return SimpleNamespace(
llmwiki=SimpleNamespace(
local_wiki_index=LocalWikiIndexConfig(enabled=True, embedding=embedding)
)
)
def test_runtime_mode_can_switch_remote_config_to_uploaded_local_model(tmp_path: Path) -> None:
configured = LocalWikiEmbeddingConfig(
provider="openai_compatible",
model="remote-embedding",
base_url="https://embedding.example.test/v1",
dimensions=1536,
)
source = _source_config(configured)
normal = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
source,
LocalWikiEncoderSettings(mode="configured"),
)
assert normal.embedding.provider == "openai_compatible"
assert normal.embedding.model == "remote-embedding"
assert normal.embedding.dimensions == 1536
local = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
source,
LocalWikiEncoderSettings(
mode="local",
model_path=str(tmp_path),
model_revision="revision-2",
),
)
assert local.embedding.provider == "local"
assert local.embedding.model == "bge-embedding-m3"
assert local.embedding.dimensions == 1024
assert local.embedding.local_model_path == str(tmp_path)
assert local.embedding.local_model_revision == "revision-2"
assert local.embedding.base_url == ""
assert local.embedding.api_key == ""
def test_local_model_revision_changes_embedding_fingerprint(tmp_path: Path) -> None:
common = {
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"local_model_path": str(tmp_path),
}
first = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_revision="first")
)
second = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_revision="second")
)
assert first.fingerprint != second.fingerprint
def test_local_model_path_changes_embedding_fingerprint(tmp_path: Path) -> None:
first_path = tmp_path / "revision-a"
second_path = tmp_path / "revision-b"
common = {
"provider": "local",
"model": "bge-embedding-m3",
"dimensions": 1024,
"local_model_revision": "same-revision",
}
first = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_path=str(first_path))
)
second = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(**common, local_model_path=str(second_path))
)
assert first.fingerprint != second.fingerprint
def test_uploaded_local_mode_always_uses_bge_m3_contract(tmp_path: Path) -> None:
configured = LocalWikiEmbeddingConfig(
provider="local",
model="another-local-model",
local_model_path=str(tmp_path / "configured"),
dimensions=384,
)
effective = llmwiki_index_runtime.resolve_effective_local_wiki_index_config(
_source_config(configured),
LocalWikiEncoderSettings(
mode="local",
model_path=str(tmp_path / "uploaded"),
model_revision="uploaded-revision",
),
)
assert effective.embedding.provider == "local"
assert effective.embedding.model == "bge-embedding-m3"
assert effective.embedding.dimensions == 1024
assert effective.embedding.local_model_path == str(tmp_path / "uploaded")
def test_local_provider_never_uses_residual_base_url_as_readiness(tmp_path: Path) -> None:
config = LocalWikiIndexConfig(
enabled=True,
embedding=LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
local_model_path=str(tmp_path / "missing"),
base_url="https://stale.example.test/v1",
dimensions=1024,
),
)
assert llmwiki_index_runtime._embedding_provider_ready(config) is False
@pytest.mark.asyncio
async def test_retired_embedding_rejects_future_work() -> None:
client = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(
provider="openai",
model="text-embedding-3-small",
base_url="https://api.openai.com/v1",
dimensions=1536,
)
)
await client.retire()
with pytest.raises(WikiEmbeddingError) as exc_info:
await client.embed_query("retry me")
assert getattr(exc_info.value, "category", None) == "runtime_retired"
@pytest.mark.asyncio
async def test_cancelled_local_embedding_drains_worker_thread_before_releasing_lock(
monkeypatch,
) -> None:
client = StrictWikiEmbeddingClient(
LocalWikiEmbeddingConfig(
provider="local",
model="bge-embedding-m3",
dimensions=2,
)
)
started = Event()
release = Event()
def blocking_embed(_texts):
started.set()
release.wait(timeout=5)
return [[1.0, 0.0]]
monkeypatch.setattr(client, "_embed_local", blocking_embed)
embedding_task = asyncio.create_task(client.embed_texts(["text"]))
assert await asyncio.to_thread(started.wait, 1)
embedding_task.cancel()
await asyncio.sleep(0)
assert not embedding_task.done()
assert client._active_uses == 1
embedding_task.cancel()
await asyncio.sleep(0)
assert not embedding_task.done()
assert client._active_uses == 1
release.set()
with pytest.raises(asyncio.CancelledError):
await embedding_task
assert client._active_uses == 0
@pytest.mark.asyncio
async def test_status_is_available_for_remote_provider(monkeypatch) -> None:
configured = LocalWikiEmbeddingConfig(
provider="openai",
model="text-embedding-3-small",
base_url="https://api.openai.com/v1",
dimensions=1536,
)
app = FastAPI()
app.state.config = _source_config(configured)
app.state.llmwiki_effective_config = app.state.config.llmwiki.local_wiki_index
app.state.llmwiki_embedding = StrictWikiEmbeddingClient(configured)
request = Request({"type": "http", "headers": [], "app": app})
async def admin(_request):
return "admin", True
async def unchanged(_app, *, force=False):
return _app.state.llmwiki_effective_config
monkeypatch.setattr(llmwiki_index, "_actor", admin)
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", unchanged)
monkeypatch.setattr(
llmwiki_index_runtime,
"load_system_settings",
lambda: SystemSettings(
local_wiki_encoder=LocalWikiEncoderSettings(mode="configured")
),
)
result = await llmwiki_index.local_encoder_status(request)
assert result["provider"] == "openai"
assert result["configured_provider"] == "openai"
assert result["mode"] == "configured"
assert result["using_local"] is False
assert result["state"] == "idle"
@pytest.mark.asyncio
async def test_local_path_save_and_restore_persist_mode_and_rebuild(
monkeypatch,
tmp_path: Path,
) -> None:
model_dir = tmp_path / "bge-m3"
(model_dir / "onnx").mkdir(parents=True)
(model_dir / "onnx" / "model.onnx").write_bytes(b"onnx")
app = FastAPI()
request = Request({"type": "http", "headers": [], "app": app})
saved: list[SystemSettings] = []
current = SystemSettings()
rebuilds: list[bool] = []
async def admin(_request):
return "admin", True
async def rebuilt(_app, *, force=False):
rebuilds.append(force)
return LocalWikiIndexConfig()
monkeypatch.setattr(llmwiki_index, "_actor", admin)
monkeypatch.setattr(llmwiki_index, "load_system_settings", lambda: saved[-1] if saved else current)
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", rebuilt)
monkeypatch.setattr(
llmwiki_index,
"local_encoder_runtime_status",
lambda _app: {
"provider": "local" if saved[-1].local_wiki_encoder.mode == "local" else "openai",
"mode": saved[-1].local_wiki_encoder.mode,
},
)
local = await llmwiki_index.configure_local_encoder(
request,
llmwiki_index.LocalEncoderConfigRequest(model_path=str(model_dir)),
)
assert local["mode"] == "local"
assert saved[-1].local_wiki_encoder.mode == "local"
assert saved[-1].local_wiki_encoder.model_revision
configured = await llmwiki_index.use_configured_encoder(request)
assert configured["mode"] == "configured"
assert saved[-1].local_wiki_encoder.mode == "configured"
assert rebuilds == [True, True]
def test_both_shipped_offline_configs_enable_incremental_wiki_rescan() -> None:
offline_root = Path(__file__).resolve().parents[2]
for path in (offline_root / "config.yaml", offline_root / "backend" / "config.yaml"):
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
local_index = payload["llmwiki"]["local_wiki_index"]
assert local_index["enabled"] is True
assert local_index["auto_sync"] is True
@@ -497,129 +497,6 @@ async def test_weknora_client_resolves_default_embedding_model_inside_weknora()
assert created["id"] == "kb-created" assert created["id"] == "kb-created"
@pytest.mark.asyncio
async def test_gateway_lists_safe_embedding_models_and_uses_explicit_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.gateway.routers import llmwiki as llmwiki_router
create_calls: list[dict[str, object]] = []
class Client:
async def list_models(self) -> list[dict[str, object]]:
return [
{
"id": "embed-local",
"type": "Embedding",
"name": "Local BGE",
"provider": "local",
"status": "active",
"is_default": True,
"api_key": "must-not-leak",
"config": {"secret": "must-not-leak"},
},
{
"id": "embed-openai",
"type": "embedding",
"display_name": "OpenAI text-embedding",
"source": "remote",
"parameters": {
"model": "text-embedding-3-large",
"provider": "openai",
"api_key": "must-not-leak",
},
"status": "active",
},
{
"id": "embed-disabled",
"type": "Embedding",
"name": "Disabled",
"status": "inactive",
},
{"id": "chat-one", "type": "LLM", "name": "chat"},
]
async def create_knowledge_base(self, **kwargs: object) -> dict[str, object]:
create_calls.append(kwargs)
return {
"id": "remote-openai",
"name": kwargs["name"],
"description": kwargs["description"],
"type": kwargs["kb_type"],
"embedding_model_id": kwargs["embedding_model_id"],
}
async def actor(_: object) -> tuple[str, bool]:
return "user-a", False
store = MemoryLlmWikiStore()
client = Client()
monkeypatch.setattr(llmwiki_router, "_actor", actor)
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: client)
monkeypatch.setattr(llmwiki_router, "_store", lambda _: store)
monkeypatch.setattr(llmwiki_router, "_schedule_local_wiki_sync", lambda *_: None)
listed = await llmwiki_router.list_embedding_models(object())
assert listed["default_model_id"] == "embed-local"
assert [item["id"] for item in listed["models"]] == [
"embed-local",
"embed-openai",
"embed-disabled",
]
assert listed["models"][1]["provider"] == "openai"
assert listed["models"][1]["model_name"] == "text-embedding-3-large"
assert listed["models"][2]["available"] is False
assert "api_key" not in listed["models"][0]
assert "config" not in listed["models"][0]
assert "parameters" not in listed["models"][1]
created = await llmwiki_router.create_knowledge_base(
object(),
llmwiki_router.KnowledgeBaseCreate(
name="OpenAI docs",
embedding_model_id="embed-openai",
),
)
assert create_calls[0]["embedding_model_id"] == "embed-openai"
assert created["embedding_model_id"] == "embed-openai"
for rejected_model_id in ("embed-disabled", "chat-one", "missing"):
with pytest.raises(HTTPException) as exc_info:
await llmwiki_router.create_knowledge_base(
object(),
llmwiki_router.KnowledgeBaseCreate(
name="Rejected docs",
embedding_model_id=rejected_model_id,
),
)
assert exc_info.value.status_code == 422
assert len(create_calls) == 1
@pytest.mark.asyncio
async def test_gateway_embedding_model_list_allows_empty_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.gateway.routers import llmwiki as llmwiki_router
class Client:
async def list_models(self) -> list[dict[str, object]]:
return [{"id": "chat-only", "type": "LLM"}]
async def actor(_: object) -> tuple[str, bool]:
return "user-a", False
monkeypatch.setattr(llmwiki_router, "_actor", actor)
monkeypatch.setattr(llmwiki_router, "_client_or_503", lambda: Client())
assert await llmwiki_router.list_embedding_models(object()) == {
"models": [],
"default_model_id": None,
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_weknora_client_creates_wiki_with_weknora_owned_models() -> None: async def test_weknora_client_creates_wiki_with_weknora_owned_models() -> None:
model_reads = 0 model_reads = 0
@@ -199,101 +199,34 @@ async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> Non
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auto_sync_scheduler_scans_immediately_then_only_when_due(monkeypatch) -> None: async def test_auto_sync_scheduler_scans_immediately_then_waits(monkeypatch) -> None:
now = 0.0 calls: list[str] = []
refreshes: list[float] = []
syncs: list[float] = []
sleeps: list[float] = []
config = SimpleNamespace(
enabled=True,
auto_sync=True,
sync_interval_seconds=25,
)
async def fake_refresh(_app):
refreshes.append(now)
return config
async def fake_sleep(seconds: float) -> None: async def fake_sleep(seconds: float) -> None:
nonlocal now calls.append(f"sleep:{seconds}")
sleeps.append(seconds) raise asyncio.CancelledError
if len(syncs) >= 2:
async def fake_sync_once(app) -> None:
calls.append("sync")
if calls.count("sync") > 1:
raise asyncio.CancelledError raise asyncio.CancelledError
now += seconds
async def fake_sync_once(_app, *, config=None) -> None:
syncs.append(now)
monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now)
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep) monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once) monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
monkeypatch.setattr(
llmwiki_index_scheduler,
"_refresh_local_wiki_config",
fake_refresh,
)
app = SimpleNamespace( app = SimpleNamespace(
state=SimpleNamespace( state=SimpleNamespace(
llmwiki_sync_service=object(), config=SimpleNamespace(
llmwiki_store=object(), llmwiki=SimpleNamespace(
local_wiki_index=SimpleNamespace(sync_interval_seconds=120),
)
)
) )
) )
with pytest.raises(asyncio.CancelledError): with pytest.raises(asyncio.CancelledError):
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app) await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
assert refreshes == [0.0, 10.0, 20.0, 30.0] assert calls == ["sync", "sleep:120"]
assert syncs == [0.0, 30.0]
assert sleeps == [10.0, 10.0, 10.0, 10.0]
@pytest.mark.asyncio
async def test_auto_sync_scheduler_refreshes_provider_between_syncs(monkeypatch) -> None:
now = 0.0
seen_providers: list[str] = []
syncs: list[str] = []
providers = iter(("openai", "local", "local"))
async def fake_refresh(_app):
provider = next(providers)
seen_providers.append(provider)
_app.state.llmwiki_runtime_signature = provider
return SimpleNamespace(
enabled=True,
auto_sync=True,
sync_interval_seconds=120,
embedding=SimpleNamespace(provider=provider),
)
async def fake_sync_once(_app, *, config=None) -> None:
syncs.append(config.embedding.provider)
async def fake_sleep(seconds: float) -> None:
nonlocal now
now += seconds
if len(seen_providers) >= 3:
raise asyncio.CancelledError
monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now)
monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep)
monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once)
monkeypatch.setattr(
llmwiki_index_scheduler,
"_refresh_local_wiki_config",
fake_refresh,
)
app = SimpleNamespace(
state=SimpleNamespace(
llmwiki_sync_service=object(),
llmwiki_store=object(),
)
)
with pytest.raises(asyncio.CancelledError):
await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app)
assert seen_providers == ["openai", "local", "local"]
assert syncs == ["openai", "local"]
def test_local_wiki_index_defaults_to_automatic_sync() -> None: def test_local_wiki_index_defaults_to_automatic_sync() -> 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.prompt_prefix.ordinary_qa_markdown_format_enabled is False
assert settings.citation_display.reference_mode_enabled is False assert settings.citation_display.reference_mode_enabled is False
assert settings.citation_display.cleanup_words == [] assert settings.citation_display.cleanup_words == []
assert settings.memory.enabled is False
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None: 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"] == ["广告", "免责声明"] 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: def test_prepend_prefix_tags_first_human_without_touching_content() -> None:
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]} graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度") out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
@@ -1,10 +1,8 @@
"""Regression coverage for provider-safe transient context compression.""" """Regression coverage for provider-safe transient context compression."""
from langchain.agents.middleware.types import ModelRequest, ModelResponse
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from deerflow.agents.middlewares.summarization_middleware import DeerFlowSummarizationMiddleware, _ensure_latest_human_message from deerflow.agents.middlewares.summarization_middleware import _ensure_latest_human_message
def test_compression_restores_latest_human_turn_when_only_tool_tail_remains(): def test_compression_restores_latest_human_turn_when_only_tool_tail_remains():
@@ -30,37 +28,3 @@ def test_compression_does_not_duplicate_human_turn_already_in_tail():
result = _ensure_latest_human_message([user], compressed) result = _ensure_latest_human_message([user], compressed)
assert result == compressed assert result == compressed
def test_compression_uses_single_model_request_system_slot_for_generic_adapters():
model = FakeListChatModel(responses=["condensed history"])
middleware = DeerFlowSummarizationMiddleware(
model,
trigger=("messages", 3),
keep=("messages", 1),
)
request = ModelRequest(
model=model,
system_message=SystemMessage(content="agent prompt"),
messages=[
HumanMessage(content="first question", id="human-1"),
AIMessage(content="first answer", id="ai-1"),
HumanMessage(content="continue", id="human-2"),
],
)
captured: list[ModelRequest] = []
def handler(model_request: ModelRequest) -> ModelResponse:
captured.append(model_request)
return ModelResponse(result=[AIMessage(content="ok")])
middleware.wrap_model_call(request, handler)
assert len(captured) == 1
normalized = captured[0]
assert normalized.system_message is not None
assert "agent prompt" in normalized.system_message.text
assert "condensed history" in normalized.system_message.text
assert all(not isinstance(message, SystemMessage) for message in normalized.messages)
assert [message.type for message in normalized.messages] == ["human"]
assert normalized.messages[0].content == "continue"
@@ -8,7 +8,7 @@ import pytest
from fastapi import FastAPI, UploadFile from fastapi import FastAPI, UploadFile
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 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.archive import PackageArchive
from deerflow.assistant_knowledge.package_io import write_package from deerflow.assistant_knowledge.package_io import write_package
from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig 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 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 @pytest.mark.asyncio
async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp_path, monkeypatch): async def test_metadata_vectors_and_links_survive_roundtrip_without_encoding(tmp_path, monkeypatch):
engine = create_async_engine("sqlite+aiosqlite:///:memory:") engine = create_async_engine("sqlite+aiosqlite:///:memory:")
@@ -395,10 +375,8 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
class Embedding: class Embedding:
def __init__(self): def __init__(self):
self.model_path = "" self.model_path = ""
self.reset_calls = 0
async def reset_local_encoder(self, *, model_path=None): async def reset_local_encoder(self, *, model_path=None):
self.reset_calls += 1
if model_path is not None: if model_path is not None:
self.model_path = model_path self.model_path = model_path
@@ -417,17 +395,6 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
monkeypatch.setattr(llmwiki_index, "load_system_settings", SystemSettings) monkeypatch.setattr(llmwiki_index, "load_system_settings", SystemSettings)
monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value) monkeypatch.setattr(llmwiki_index, "save_system_settings", lambda value: saved.append(value) or value)
embedding = Embedding() embedding = Embedding()
async def rebuild(_app, *, force=False):
assert force is True
embedding.model_path = saved[-1].local_wiki_encoder.model_path
monkeypatch.setattr(llmwiki_index, "rebuild_local_wiki_index_runtime", rebuild)
monkeypatch.setattr(
llmwiki_index,
"local_encoder_runtime_status",
lambda _app: embedding.local_encoder_status(),
)
app = FastAPI() app = FastAPI()
app.state.config = SimpleNamespace( app.state.config = SimpleNamespace(
llmwiki=SimpleNamespace( llmwiki=SimpleNamespace(
@@ -447,15 +414,11 @@ async def test_admin_can_upload_and_activate_offline_encoder(monkeypatch, tmp_pa
paths=["onnx/model.onnx", "config.json"], paths=["onnx/model.onnx", "config.json"],
) )
revision = saved[-1].local_wiki_encoder.model_revision active = tmp_path / "models" / "bge-m3"
active = tmp_path / "models" / "bge-m3-revisions" / revision
assert (active / "onnx" / "model.onnx").read_bytes() == b"onnx" assert (active / "onnx" / "model.onnx").read_bytes() == b"onnx"
assert result["uploaded_files"] == 2 assert result["uploaded_files"] == 2
assert saved[-1].local_wiki_encoder.model_path == str(active.resolve()) assert saved[-1].local_wiki_encoder.model_path == str(active.resolve())
assert saved[-1].local_wiki_encoder.mode == "local"
assert revision
assert embedding.model_path == str(active.resolve()) assert embedding.model_path == str(active.resolve())
assert embedding.reset_calls == 0
def test_encoder_upload_rejects_parent_traversal(): def test_encoder_upload_rejects_parent_traversal():
-27
View File
@@ -116,33 +116,6 @@ vision:
# max_tokens: 2048 # max_tokens: 2048
knowledge_ingest: knowledge_ingest:
url: "https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add" url: "https://ch1.b.uat.4.cn/knowes/api/knowledge-outer/add"
# WeKnora connection can be supplied from the administrator settings page.
# Keep release credentials out of this portable offline template; a non-empty
# runtime URL activates WeKnora and hot-rebuilds the Wiki sync service.
llmwiki:
weknora:
api_base_url: ""
web_base_url: ""
admin_email: ""
admin_password: ""
local_wiki_index:
enabled: true
frontend_base_url: "http://127.0.0.1:5174"
# WeKnora Wiki generation is asynchronous, so the immediate scan alone can
# miss newly-created pages. Keep the incremental rescan enabled.
auto_sync: true
sync_interval_seconds: 30
database_batch_size: 50
embedding:
provider: local
model: bge-embedding-m3
dimensions: 1024
batch_size: 2
local_threads: 2
local_process_isolation: true
local_model_path: ""
base_url: ""
api_key: ""
# 上传文件识别配置。 # 上传文件识别配置。
# - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为 # - auto_convert_documents: true 时,上传的 PDF / Word / Excel / PPT 会在服务端自动转换为
# Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。 # Markdown(.md),agent 通过 read_file / grep 读取其内容 —— 即“识别”文档内容。
+2 -2
View File
@@ -58,8 +58,8 @@ nohup bash -c '
--reload \ --reload \
--reload-dir "app" \ --reload-dir "app" \
--reload-dir "packages" \ --reload-dir "packages" \
--reload-exclude ".deer-flow" \ --reload-exclude "$0/.deer-flow" \
--reload-exclude ".runtime" --reload-exclude "$0/.runtime"
' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \ ' "${BACKEND_DIR}" "${GATEWAY_CORS_ORIGINS}" "${BACKEND_HOST}" "${BACKEND_PORT}" "${BACKEND_VENDOR_DIR}" \
>"${LOG_FILE}" 2>&1 & >"${LOG_FILE}" 2>&1 &