# Conflicts:
#	frontend-web/src/components/workspace/messages/message-group.tsx
#	offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py
This commit is contained in:
fengxiangdebaozi committed 2026-08-31 20:12:05 +08:00
commit 57b6a89f20
21 files changed
+441 -115

No files matched your search

@@ -22,6 +22,7 @@ import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-f
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag"; import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
@@ -87,7 +88,11 @@ const ChatBox: React.FC<{
threadId, threadId,
disableArtifacts = false, disableArtifacts = false,
}) => { }) => {
const { isMock, thread } = useThread(); const {
isMock,
thread,
knowledgeReferencesEnabled = false,
} = useThread();
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const artifactsCountRef = useRef(0); const artifactsCountRef = useRef(0);
@@ -106,8 +111,13 @@ const ChatBox: React.FC<{
const writingModeEnabled = !!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 referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); (referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
// Frontend RAG modes write their own hidden source tool messages. They use // Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference // the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs. // batches through DeerFlow's backend APIs.
@@ -121,7 +131,7 @@ const ChatBox: React.FC<{
); );
const referencesEnabled = ragModeEnabled || hasFrontendRag; const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches = const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag; referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new";
const { skills } = useSkills(); const { skills } = useSkills();
const canScanArtifactDirectory = const canScanArtifactDirectory =
@@ -93,14 +93,14 @@ import {
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useUserPreferences } from "@/core/user-preferences"; import { useUserPreferences } from "@/core/user-preferences";
import { import {
isThreadRagModeLocked, isThreadRagModeLocked,
lockThreadRagMode, lockThreadRagMode,
useLocalSettings, useLocalSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu"; import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -221,9 +221,12 @@ export function InputBox({
const modelOptions = useMemo(() => { const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models; return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]); }, [extraModels, models, singleRoundRagActive]);
const { thread, isMock } = useThread(); const { thread, isMock } = useThread();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]); const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -830,9 +833,9 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip>} </Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip {referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={ content={
ragModeEnabled ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启" : "参考文献模式已关闭:点击开启"
} }
@@ -4,11 +4,17 @@ import { createContext, useContext } from "react";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime"; import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType { export interface ThreadContextType {
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
isMock?: boolean; isMock?: boolean;
/** /**
* When set, the conversation is rendered from a public read-only share. * True when the active conversation has selected LLMWiki knowledge bases.
* Knowledge-base Q&A should always expose its source documents, independent
* from the admin-controlled manual reference-mode button.
*/
knowledgeReferencesEnabled?: boolean;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated * Artifact/image URLs then resolve to the unauthenticated
* `/api/public/shares/{code}/files/...` endpoint instead of the * `/api/public/shares/{code}/files/...` endpoint instead of the
* auth-gated thread artifact route. * auth-gated thread artifact route.
@@ -83,16 +83,18 @@ import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item"; import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({ export function MessageGroup({
className, className,
messages, messages,
isLoading = false, isLoading = false,
displaySources = [], displaySources = [],
}: { ragModeEnabled = false,
className?: string; }: {
messages: Message[]; className?: string;
isLoading?: boolean; messages: Message[];
displaySources?: RuntimeDisplaySource[]; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
@@ -110,9 +112,9 @@ export function MessageGroup({
[messages, writingModeEnabled], [messages, writingModeEnabled],
); );
const { skills } = useSkills(); const { skills } = useSkills();
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills], [ragModeEnabled, skills],
); );
const lastToolCallStep = useMemo(() => { const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -207,7 +209,7 @@ export function MessageGroup({
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
)} )}
@@ -219,7 +221,7 @@ export function MessageGroup({
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
@@ -28,11 +28,12 @@ import {
hasToolCalls, hasToolCalls,
isHiddenFromUIMessage, isHiddenFromUIMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
buildSkillDisplayMap, import {
buildSkillDisplayMap,
collectRuntimeDisplaySources, collectRuntimeDisplaySources,
type RuntimeDisplaySource, type RuntimeDisplaySource,
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
@@ -299,10 +300,17 @@ export function MessageList({
} }
return map; return map;
}, [messages]); }, [messages]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const ragModeEnabled = const { data: citationDisplay } = useCitationDisplaySettings();
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const knowledgeReferencesEnabled =
threadContext?.knowledgeReferencesEnabled === true;
const ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
knowledgeReferencesEnabled;
const hasSingleRoundRag = useMemo( const hasSingleRoundRag = useMemo(
() => () =>
messages.some( messages.some(
@@ -778,12 +786,13 @@ export function MessageList({
)) { )) {
if (hasReasoning(message)) { if (hasReasoning(message)) {
results.push( results.push(
<MessageGroup <MessageGroup
key={"thinking-group-" + message.id} key={"thinking-group-" + message.id}
messages={[message]} messages={[message]}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)} displaySources={resolveDisplaySources(message)}
/>, ragModeEnabled={ragModeEnabled}
/>,
); );
} }
results.push( results.push(
@@ -831,11 +840,12 @@ export function MessageList({
.find((message) => message.type === "ai"); .find((message) => message.type === "ai");
return ( return (
<div key={"group-" + group.id} className="w-full"> <div key={"group-" + group.id} className="w-full">
<MessageGroup <MessageGroup
messages={group.messages} messages={group.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)} displaySources={resolveDisplaySources(processingAi)}
/> ragModeEnabled={ragModeEnabled}
/>
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={thread.isLoading} isLoading={thread.isLoading}
@@ -205,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode(); const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords = const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
useEffect(() => { useEffect(() => {
setShortcutDraft( setShortcutDraft(
@@ -845,23 +847,45 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
aria-label="回答速度指示器" aria-label="回答速度指示器"
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4"> {isAdmin && (
<div> <div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div className="text-sm font-medium">RAG 引用模式</div> <div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed"> <div className="text-sm font-medium">参考文献模式入口</div>
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 <p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
</p> 管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
</div> </p>
<Switch </div>
checked={settings.display.ragModeEnabled} <Switch
onCheckedChange={(checked) => checked={referenceModeEntryEnabled}
setSettings("display", { ragModeEnabled: checked }) disabled={updateCitationDisplay.isPending}
} onCheckedChange={(checked) =>
aria-label="RAG 引用模式" updateCitationDisplay.mutate({
/> reference_mode_enabled: checked,
</div> })
{isAdmin && ( }
<div className="rounded-lg border p-4"> aria-label="参考文献模式入口"
/>
</div>
)}
{referenceModeEntryEnabled && (
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
)}
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3"> <div className="mb-3">
<div className="text-sm font-medium">参考文献去除词</div> <div className="text-sm font-medium">参考文献去除词</div>
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed"> <p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
@@ -6,6 +6,7 @@ export interface PromptPrefixSettings {
enabled: boolean; enabled: boolean;
switch_label: string; switch_label: string;
prompt_prefix: string; prompt_prefix: string;
ordinary_qa_markdown_format_enabled: boolean;
} }
export interface PluginDomainAgentMappingDto { export interface PluginDomainAgentMappingDto {
@@ -37,6 +38,7 @@ export interface PromptPrefixUpdate {
enabled?: boolean; enabled?: boolean;
switch_label?: string; switch_label?: string;
prompt_prefix?: string; prompt_prefix?: string;
ordinary_qa_markdown_format_enabled?: boolean;
} }
function fromPluginManagementDto(data: { function fromPluginManagementDto(data: {
@@ -173,10 +175,12 @@ export interface PrivateSquareUpdate {
} }
export interface CitationDisplaySettings { export interface CitationDisplaySettings {
reference_mode_enabled: boolean;
cleanup_words: string[]; cleanup_words: string[];
} }
export interface CitationDisplayUpdate { export interface CitationDisplayUpdate {
reference_mode_enabled?: boolean;
cleanup_words?: string[]; cleanup_words?: string[];
} }
+34 -16
View File
@@ -16,9 +16,14 @@ import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL, getLangGraphBaseURL } from "../config"; import { getBackendBaseURL, getLangGraphBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log"; import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings"; import {
isThreadRagModeLocked,
lockThreadRagMode,
useLocalSettings,
} from "../settings";
import { useCitationDisplaySettings } from "../system-settings/hooks";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -566,8 +571,11 @@ export function useThreadStream({
onToolStart, onToolStart,
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1499,15 +1507,15 @@ export function useThreadStream({
...context, ...context,
...extraContext, ...extraContext,
}; };
const selectedLlmWikiIds = Array.isArray( const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids, mergedContext.llmwiki_knowledge_base_ids,
) )
? mergedContext.llmwiki_knowledge_base_ids ? mergedContext.llmwiki_knowledge_base_ids
: []; : [];
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || (referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) || isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0; selectedLlmWikiIds.length > 0;
if (ragModeEnabled) { if (ragModeEnabled) {
lockThreadRagMode(threadId); lockThreadRagMode(threadId);
} }
@@ -1625,8 +1633,18 @@ export function useThreadStream({
sendInFlightRef.current = false; sendInFlightRef.current = false;
} }
}, },
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions, confirmOrRejoin], [
); thread,
_handleOnStart,
t.uploads.uploadingFiles,
context,
referenceModeEntryEnabled,
localSettings.display.ragModeEnabled,
queryClient,
runOptions,
confirmOrRejoin,
],
);
const handleThreadStop = useCallback(async () => { const handleThreadStop = useCallback(async () => {
const tid = threadIdRef.current; const tid = threadIdRef.current;
+7 -1
View File
@@ -777,7 +777,13 @@ export default function AgentChatPage({
: "ready"; : "ready";
return ( return (
<ThreadContext.Provider value={{ thread: displayThread }}> <ThreadContext.Provider
value={{
thread: displayThread,
knowledgeReferencesEnabled:
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
}}
>
{/* Root: fixed-height horizontal split. ChatBox (chat + artifact {/* Root: fixed-height horizontal split. ChatBox (chat + artifact
preview) lives in the left flex cell; the agent detail panel is a preview) lives in the left flex cell; the agent detail panel is a
separate flex sibling OUTSIDE ChatBox, so the artifact preview separate flex sibling OUTSIDE ChatBox, so the artifact preview
+8 -1
View File
@@ -684,7 +684,14 @@ export default function ChatPage() {
!isWujieEnv(); !isWujieEnv();
return ( return (
<ThreadContext.Provider value={{ thread: displayThread, isMock }}> <ThreadContext.Provider
value={{
thread: displayThread,
isMock,
knowledgeReferencesEnabled:
knowledgeSelectionReady && selectedKnowledgeBaseIds.length > 0,
}}
>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
<div <div
className={cn( className={cn(
+12 -2
View File
@@ -39,8 +39,14 @@ import {
useLocalSettings, useLocalSettings,
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks"; import {
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks"; useCitationDisplaySettings,
usePluginManagementSettings,
} from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode";
import { env } from "@/env"; import { env } from "@/env";
@@ -379,6 +385,9 @@ export default function IframeChatPage() {
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]); }, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeLocked = isThreadRagModeLocked(threadId);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled = const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked; localSettings.display.ragModeEnabled || ragModeLocked;
const contextCompressionEnabled = const contextCompressionEnabled =
@@ -525,6 +534,7 @@ export default function IframeChatPage() {
memoryInjectionEnabled: Boolean( memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true, settings.context.memory_injection_enabled ?? true,
), ),
referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
contextCompressionEnabled, contextCompressionEnabled,
@@ -46,6 +46,8 @@ export default function PromptPrefixSettingsPage({
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState(""); const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState(""); const [promptPrefix, setPromptPrefix] = useState("");
const [ordinaryQaMarkdownFormatEnabled, setOrdinaryQaMarkdownFormatEnabled] =
useState(false);
// Only seed the draft from server data once. Re-syncing every time react-query // Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the // refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click // user's in-progress edits — and made the toggle look unresponsive: a click
@@ -57,6 +59,9 @@ export default function PromptPrefixSettingsPage({
setEnabled(data.enabled); setEnabled(data.enabled);
setSwitchLabel(data.switch_label); setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix); setPromptPrefix(data.prompt_prefix);
setOrdinaryQaMarkdownFormatEnabled(
data.ordinary_qa_markdown_format_enabled === true,
);
initializedRef.current = true; initializedRef.current = true;
}, [data]); }, [data]);
@@ -79,6 +84,8 @@ export default function PromptPrefixSettingsPage({
enabled, enabled,
switch_label: switchLabel, switch_label: switchLabel,
prompt_prefix: promptPrefix, prompt_prefix: promptPrefix,
ordinary_qa_markdown_format_enabled:
ordinaryQaMarkdownFormatEnabled,
}); });
toast.success("已保存",darkToastOpts.success); toast.success("已保存",darkToastOpts.success);
} catch (e) { } catch (e) {
@@ -95,10 +102,9 @@ export default function PromptPrefixSettingsPage({
} }
> >
<div> <div>
<h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答前缀配置</h1> <h1 className={embedded ? "text-base font-semibold" : "text-xl font-semibold"}>问答提示词配置</h1>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时, 管理员可在此配置问答前缀和普通问答的固定 Markdown 格式提示词。
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p> </p>
</div> </div>
@@ -147,6 +153,21 @@ export default function PromptPrefixSettingsPage({
/> />
</div> </div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">固定格式提示词</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,普通问答的长篇内容会按“# 主标题、## 一、标题、###
(一) 标题、#### 1. 标题”的固定 Markdown 格式生成。默认关闭。
</p>
</div>
<Switch
checked={ordinaryQaMarkdownFormatEnabled}
onCheckedChange={setOrdinaryQaMarkdownFormatEnabled}
aria-label="固定格式提示词"
/>
</div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}> <Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && ( {update.isPending && (
@@ -9,6 +9,7 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
agentSelected: false, agentSelected: false,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true, memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
contextCompressionEnabled: false, contextCompressionEnabled: false,
@@ -28,11 +29,27 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
); );
}); });
test("hides the references toggle when reference mode is disabled globally", () => {
const buttons = buildIframeModeButtons({
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: false,
ragModeEnabled: true,
ragModeLocked: false,
contextCompressionEnabled: false,
proModeEnabled: false,
});
assert.equal(buttons.some((button) => button.key === "references"), false);
});
test("returns only concrete toolbar button configs", () => { test("returns only concrete toolbar button configs", () => {
const buttons = buildIframeModeButtons({ const buttons = buildIframeModeButtons({
agentSelected: true, agentSelected: true,
writingModeEnabled: false, writingModeEnabled: false,
memoryInjectionEnabled: true, memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false, ragModeEnabled: false,
ragModeLocked: false, ragModeLocked: false,
contextCompressionEnabled: false, contextCompressionEnabled: false,
@@ -15,6 +15,7 @@ type BuildIframeModeButtonsInput = {
agentSelected: boolean; agentSelected: boolean;
writingModeEnabled: boolean; writingModeEnabled: boolean;
memoryInjectionEnabled: boolean; memoryInjectionEnabled: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean; ragModeEnabled: boolean;
ragModeLocked: boolean; ragModeLocked: boolean;
contextCompressionEnabled: boolean; contextCompressionEnabled: boolean;
@@ -25,12 +26,13 @@ export function buildIframeModeButtons({
agentSelected, agentSelected,
writingModeEnabled, writingModeEnabled,
memoryInjectionEnabled, memoryInjectionEnabled,
referenceModeEntryEnabled,
ragModeEnabled, ragModeEnabled,
ragModeLocked, ragModeLocked,
contextCompressionEnabled, contextCompressionEnabled,
proModeEnabled, proModeEnabled,
}: BuildIframeModeButtonsInput): IframeModeButtonState[] { }: BuildIframeModeButtonsInput): IframeModeButtonState[] {
return [ const buttons: IframeModeButtonState[] = [
{ {
key: "writing", key: "writing",
label: "写作", label: "写作",
@@ -63,4 +65,7 @@ export function buildIframeModeButtons({
active: agentSelected, active: agentSelected,
}, },
]; ];
return referenceModeEntryEnabled
? buttons
: buttons.filter((button) => button.key !== "references");
} }
+15 -1
View File
@@ -357,7 +357,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` | | **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The default ordinary-Q&A Lead Agent prompt emits the matching Markdown heading forms and avoids unordered bullet lists; writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. | | **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The ordinary-Q&A Lead Agent can emit the matching Markdown heading forms and avoid unordered bullet lists when the admin enables `prompt_prefix.ordinary_qa_markdown_format_enabled` in `system_settings.json`; the switch defaults off, while writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. | | **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. | | **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing | | **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
@@ -510,6 +510,20 @@ Admin-configurable, **access-control-free** categories that published agents/ski
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries. Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
### Reference-mode Entry Switch
The 参考文献/RAG citation-mode composer entry is system-controlled through
`system_settings.json` → `citation_display.reference_mode_enabled` (default
`false`). `GET /api/system-settings/citation-display` remains readable so the
frontend can hide or show the entry consistently; `PUT` is admin-only and
partially updates `reference_mode_enabled` and/or `cleanup_words`. The normal
workspace input box, iframe chat toolbar, send context, right reference panel,
and appearance settings all use this flag, while already locked citation
threads and selected LLMWiki knowledge bases still render their source
documents in the reference panel. Tests:
`tests/test_prompt_prefix.py` and
`frontend-web/src/pages/iframe-chat-mode-buttons.test.ts`.
### Agent Module Tag Config (zzJC / zzXD 按类型聚合) ### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header). Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
@@ -679,6 +679,29 @@ login and theme query parameters, each shortcut may include ordered keyless
it as an encoded path segment (including inside a `#/…` hash route); password it as an encoded path segment (including inside a `#/…` hash route); password
remains an ordinary login query parameter with the key `password`. remains an ordinary login query parameter with the key `password`.
### Citation display settings
Reference-mode entry points are controlled by `citation_display` in
`.deer-flow/system_settings.json`. `reference_mode_enabled` defaults to `false`;
when disabled, the frontend hides the "参考文献" composer button in both the
normal workspace and iframe chat, and hides the per-user RAG citation toggle
from appearance settings. Conversations with selected LLMWiki knowledge bases
still show their source documents in the reference panel even when the manual
entry is hidden. `GET /api/system-settings/citation-display` is
readable by logged-in users so the UI can decide whether to expose the entry;
`PUT /api/system-settings/citation-display` is admin-only and supports partial
updates for both `reference_mode_enabled` and `cleanup_words`.
### Ordinary Q&A Markdown format switch
Administrators can opt into the formal long-answer Markdown prompt from the
admin "问答提示词配置" panel. The value is persisted as
`prompt_prefix.ordinary_qa_markdown_format_enabled` in
`.deer-flow/system_settings.json` and defaults to `false`. When enabled, only
ordinary Q&A receives the `# 主标题` / `## 一、标题` / `### (一) 标题` /
`#### 1. 标题` rules; writing mode, notebook mode and scheduled runs remain
unaffected. The change takes effect when the next prompt is built.
--- ---
## Technology Stack ## Technology Stack
@@ -42,12 +42,17 @@ class PromptPrefixResponse(BaseModel):
enabled: bool = Field(..., description="Whether the toggle is exposed to users") enabled: bool = Field(..., description="Whether the toggle is exposed to users")
switch_label: str = Field(..., description="Toggle label shown in the chat input") switch_label: str = Field(..., description="Toggle label shown in the chat input")
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message") prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether formal Markdown heading rules are injected into ordinary Q&A system prompts",
)
class PromptPrefixUpdateRequest(BaseModel): class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None enabled: bool | None = None
switch_label: str | None = None switch_label: str | None = None
prompt_prefix: str | None = None prompt_prefix: str | None = None
ordinary_qa_markdown_format_enabled: bool | None = None
class PluginDomainAgentMappingItem(BaseModel): class PluginDomainAgentMappingItem(BaseModel):
@@ -82,10 +87,12 @@ class PrivateSquareUpdateRequest(BaseModel):
class CitationDisplayResponse(BaseModel): class CitationDisplayResponse(BaseModel):
reference_mode_enabled: bool = Field(default=False, description="Whether reference-mode entry points are exposed to users")
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words") cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
class CitationDisplayUpdateRequest(BaseModel): class CitationDisplayUpdateRequest(BaseModel):
reference_mode_enabled: bool | None = None
cleanup_words: list[str] | None = None cleanup_words: list[str] | None = None
@@ -110,6 +117,7 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
enabled=settings.enabled, enabled=settings.enabled,
switch_label=settings.switch_label, switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix, prompt_prefix=settings.prompt_prefix,
ordinary_qa_markdown_format_enabled=settings.ordinary_qa_markdown_format_enabled,
) )
@@ -410,9 +418,12 @@ async def update_leaderboard_settings(
@router.get("/citation-display", response_model=CitationDisplayResponse) @router.get("/citation-display", response_model=CitationDisplayResponse)
async def get_citation_display() -> CitationDisplayResponse: async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings.""" """Return global citation display settings."""
settings = load_system_settings().citation_display settings = load_system_settings().citation_display
return CitationDisplayResponse(cleanup_words=settings.cleanup_words) return CitationDisplayResponse(
reference_mode_enabled=settings.reference_mode_enabled,
cleanup_words=settings.cleanup_words,
)
@router.put("/citation-display", response_model=CitationDisplayResponse) @router.put("/citation-display", response_model=CitationDisplayResponse)
@@ -420,14 +431,22 @@ async def update_citation_display(
body: CitationDisplayUpdateRequest, body: CitationDisplayUpdateRequest,
request: Request, request: Request,
) -> CitationDisplayResponse: ) -> CitationDisplayResponse:
"""Update global citation display cleanup settings. Admin only.""" """Update global citation display settings. Admin only."""
await _require_admin(request) await _require_admin(request)
current = load_system_settings() current = load_system_settings()
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()] fields = body.model_dump(exclude_unset=True)
updated = current.citation_display.model_copy(update={"cleanup_words": words}) update: dict[str, object] = {}
if "reference_mode_enabled" in fields and fields["reference_mode_enabled"] is not None:
update["reference_mode_enabled"] = bool(fields["reference_mode_enabled"])
if "cleanup_words" in fields and fields["cleanup_words"] is not None:
update["cleanup_words"] = [str(item).strip() for item in fields["cleanup_words"] if str(item).strip()]
updated = current.citation_display.model_copy(update=update)
saved = save_system_settings(current.model_copy(update={"citation_display": updated})) saved = save_system_settings(current.model_copy(update={"citation_display": updated}))
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words) return CitationDisplayResponse(
reference_mode_enabled=saved.citation_display.reference_mode_enabled,
cleanup_words=saved.citation_display.cleanup_words,
)
@router.get("/private-square", response_model=PrivateSquareStatusResponse) @router.get("/private-square", response_model=PrivateSquareStatusResponse)
@@ -9,7 +9,8 @@ from functools import lru_cache
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from deerflow.config.agents_config import load_agent_soul from deerflow.config.agents_config import load_agent_soul
from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -486,7 +487,7 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
</subagent_system>""" </subagent_system>"""
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
<ordinary_qa_markdown_format> <ordinary_qa_markdown_format>
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式: 当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。 - 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
@@ -495,10 +496,19 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。 - 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。 - 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
</ordinary_qa_markdown_format> </ordinary_qa_markdown_format>
""" """
SYSTEM_PROMPT_TEMPLATE = """ def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch."""
try:
return bool(load_system_settings().prompt_prefix.ordinary_qa_markdown_format_enabled)
except Exception:
logger.debug("Failed to read the ordinary Q&A Markdown format switch; using disabled", exc_info=True)
return False
SYSTEM_PROMPT_TEMPLATE = """
<role> <role>
You are {agent_name}, an internal super agent. You are {agent_name}, an internal super agent.
</role> </role>
@@ -1537,9 +1547,13 @@ When a search, knowledge, skill, or MCP tool returns a result list:
if rag_mode_enabled if rag_mode_enabled
else "" else ""
) )
ordinary_qa_format_section = ( ordinary_qa_format_section = (
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION _ORDINARY_QA_MARKDOWN_FORMAT_SECTION
if not is_bootstrap and not writing_mode and not is_notebook_mode and not is_scheduled_run if _ordinary_qa_markdown_format_enabled()
and not is_bootstrap
and not writing_mode
and not is_notebook_mode
and not is_scheduled_run
else "" else ""
) )
@@ -153,6 +153,10 @@ class PromptPrefixSettings(BaseModel):
enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all") enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all")
switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input") switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input")
prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled") prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled")
ordinary_qa_markdown_format_enabled: bool = Field(
default=False,
description="Whether the formal Markdown heading rules are injected into ordinary Q&A system prompts.",
)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -255,8 +259,12 @@ class AppearanceSettings(BaseModel):
class CitationDisplaySettings(BaseModel): class CitationDisplaySettings(BaseModel):
"""System-wide cleanup rules for citation cards and detail dialogs.""" """System-wide controls for citation mode entry and display cleanup."""
reference_mode_enabled: bool = Field(
default=False,
description="Whether the reference-mode entry points are exposed to users.",
)
cleanup_words: list[str] = Field( cleanup_words: list[str] = Field(
default_factory=list, default_factory=list,
description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.", description="Words or phrases used to remove noisy advertisement/disclaimer lines from citation display.",
@@ -112,6 +112,7 @@ def test_es_query_routing_registration_log_explains_custom_agent_exclusion(caplo
def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None: def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None:
config = _app_config(enabled=False) config = _app_config(enabled=False)
monkeypatch.setattr(prompt_module, "_ordinary_qa_markdown_format_enabled", lambda: True)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "") monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
@@ -134,3 +135,19 @@ def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None:
assert '<ordinary_qa_markdown_format>' not in writing_prompt assert '<ordinary_qa_markdown_format>' not in writing_prompt
assert '<ordinary_qa_markdown_format>' not in notebook_prompt assert '<ordinary_qa_markdown_format>' not in notebook_prompt
assert '<ordinary_qa_markdown_format>' not in scheduled_prompt assert '<ordinary_qa_markdown_format>' not in scheduled_prompt
def test_formal_markdown_rules_are_disabled_when_switch_is_off(monkeypatch) -> None:
config = _app_config(enabled=False)
monkeypatch.setattr(prompt_module, "_ordinary_qa_markdown_format_enabled", lambda: False)
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda *args, **kwargs: "")
monkeypatch.setattr(prompt_module, "_current_time_context", lambda: "")
ordinary_prompt = prompt_module.apply_prompt_template(app_config=config)
assert '<ordinary_qa_markdown_format>' not in ordinary_prompt
@@ -9,7 +9,9 @@ from types import SimpleNamespace
import pytest import pytest
from langchain_core.messages import AIMessage, HumanMessage from langchain_core.messages import AIMessage, HumanMessage
from app.gateway.routers import system_settings as ss_router
from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in
from deerflow.agents.lead_agent import prompt as prompt_module
from deerflow.config import system_settings as ss from deerflow.config import system_settings as ss
@@ -28,6 +30,9 @@ def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None:
settings = ss.load_system_settings() settings = ss.load_system_settings()
assert settings.prompt_prefix.enabled is False assert settings.prompt_prefix.enabled is False
assert settings.prompt_prefix.prompt_prefix == "" assert settings.prompt_prefix.prompt_prefix == ""
assert settings.prompt_prefix.ordinary_qa_markdown_format_enabled is False
assert settings.citation_display.reference_mode_enabled is False
assert settings.citation_display.cleanup_words == []
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None: def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
@@ -36,6 +41,7 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
enabled=True, enabled=True,
switch_label="加入立场前缀", switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度", prompt_prefix="你需要站在中方角度",
ordinary_qa_markdown_format_enabled=True,
) )
) )
ss.save_system_settings(settings) ss.save_system_settings(settings)
@@ -49,6 +55,88 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
assert reloaded.prompt_prefix.enabled is True assert reloaded.prompt_prefix.enabled is True
assert reloaded.prompt_prefix.switch_label == "加入立场前缀" assert reloaded.prompt_prefix.switch_label == "加入立场前缀"
assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度" assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度"
assert reloaded.prompt_prefix.ordinary_qa_markdown_format_enabled is True
def test_runtime_markdown_format_switch_defaults_off_and_updates_without_restart(
tmp_runtime_home: Path,
) -> None:
assert prompt_module._ordinary_qa_markdown_format_enabled() is False
ss.save_system_settings(
ss.SystemSettings(
prompt_prefix=ss.PromptPrefixSettings(
ordinary_qa_markdown_format_enabled=True,
)
)
)
assert prompt_module._ordinary_qa_markdown_format_enabled() is True
def _patch_admin(monkeypatch: pytest.MonkeyPatch) -> None:
admin = SimpleNamespace(
id="admin1",
system_role="admin",
email="admin@example.com",
)
async def _optional(_request):
return admin
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
@pytest.mark.asyncio
async def test_prompt_settings_update_can_enable_markdown_format_without_changing_prefix(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
current = ss.SystemSettings(
prompt_prefix=ss.PromptPrefixSettings(
enabled=True,
switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度",
)
)
ss.save_system_settings(current)
response = await ss_router.update_prompt_prefix(
ss_router.PromptPrefixUpdateRequest(ordinary_qa_markdown_format_enabled=True),
SimpleNamespace(),
)
assert response.ordinary_qa_markdown_format_enabled is True
assert response.enabled is True
assert response.switch_label == "加入立场前缀"
assert response.prompt_prefix == "你需要站在中方角度"
@pytest.mark.asyncio
async def test_citation_display_update_preserves_unspecified_fields(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
current = ss.SystemSettings(
citation_display=ss.CitationDisplaySettings(
cleanup_words=["广告", "免责声明"],
)
)
ss.save_system_settings(current)
response = await ss_router.update_citation_display(
ss_router.CitationDisplayUpdateRequest(reference_mode_enabled=True),
SimpleNamespace(),
)
assert response.reference_mode_enabled is True
assert response.cleanup_words == ["广告", "免责声明"]
payload = json.loads((tmp_runtime_home / "system_settings.json").read_text(encoding="utf-8"))
assert payload["citation_display"]["reference_mode_enabled"] is True
assert payload["citation_display"]["cleanup_words"] == ["广告", "免责声明"]
def test_prepend_prefix_tags_first_human_without_touching_content() -> None: def test_prepend_prefix_tags_first_human_without_touching_content() -> None: