feat: add configurable chat display modes

This commit is contained in:
吉鑫升 committed 2026-08-31 17:49:48 +08:00
1 parent 3715af3138
commit 40542db400
18 files changed
+421 -121

No files matched your search

@@ -20,6 +20,7 @@ import {
} from "@/core/artifacts/listing"; } from "@/core/artifacts/listing";
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";
@@ -101,8 +102,12 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
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);
// 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.
@@ -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
? "参考文献模式已开启:检索类结果会按编号引用展示" ? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:点击开启" : "参考文献模式已关闭:点击开启"
} }
@@ -51,13 +51,12 @@ import {
resolveSearchQuery, resolveSearchQuery,
type SearchResultItem, type SearchResultItem,
} from "@/core/messages/search-results"; } from "@/core/messages/search-results";
import { import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks";
import { useSkills } from "@/core/skills/hooks";
import { import {
applyInlineCitations, applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
@@ -81,17 +80,19 @@ 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(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
@@ -99,13 +100,12 @@ export function MessageGroup({
const [showLastThinking, setShowLastThinking] = useState( const [showLastThinking, setShowLastThinking] = useState(
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const steps = useMemo(() => convertToSteps(messages), [messages]); const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings(); const { skills } = useSkills();
const { skills } = useSkills(); const skillDisplayMap = useMemo(
const skillDisplayMap = useMemo( () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), [ragModeEnabled, skills],
[settings.display.ragModeEnabled, skills], );
);
const lastToolCallStep = useMemo(() => { const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
@@ -197,11 +197,11 @@ export function MessageGroup({
<ToolCall <ToolCall
key={step.id} key={step.id}
{...step} {...step}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}> <FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
@@ -209,10 +209,10 @@ export function MessageGroup({
key={lastToolCallStep.id} key={lastToolCallStep.id}
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap} skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled} ragModeEnabled={ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
</ChainOfThoughtContent> </ChainOfThoughtContent>
@@ -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,14 @@ 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 ragModeEnabled =
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo( const hasSingleRoundRag = useMemo(
() => () =>
messages.some( messages.some(
@@ -778,12 +783,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 +837,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";
@@ -588,8 +593,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,
@@ -1540,15 +1548,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);
} }
@@ -1666,8 +1674,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;
+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 { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -383,6 +389,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 =
@@ -529,6 +538,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");
} }
+14 -1
View File
@@ -324,7 +324,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 |
@@ -477,6 +477,19 @@ 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 knowledge-base selections still render their references. 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).
@@ -664,6 +664,27 @@ 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. `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)
@@ -7,10 +7,11 @@ import threading
from datetime import datetime from datetime import datetime
from functools import lru_cache 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.skills.storage import get_or_new_skill_storage from deerflow.config.system_settings import load_system_settings
from deerflow.skills.storage import get_or_new_skill_storage
from deerflow.skills.types import Skill, SkillCategory from deerflow.skills.types import Skill, SkillCategory
from deerflow.subagents import get_available_subagent_names from deerflow.subagents import get_available_subagent_names
@@ -498,6 +499,21 @@ _ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
""" """
def _ordinary_qa_markdown_format_enabled() -> bool:
"""Return the admin-controlled formal Markdown prompt switch.
Reading the small runtime settings document for each prompt build makes an
administrator's change effective for the next run without a process-level
cache or restart. Any missing/invalid setting safely falls back to off.
"""
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 = """ SYSTEM_PROMPT_TEMPLATE = """
<role> <role>
You are {agent_name}, an internal super agent. You are {agent_name}, an internal super agent.
@@ -1545,7 +1561,11 @@ When a search, knowledge, skill, or MCP tool returns a result list:
) )
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: