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
+328 -28

No files matched your search

@@ -20,6 +20,7 @@ import {
} from "@/core/artifacts/listing";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
@@ -101,8 +102,12 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
const artifactDirectoryModeEnabled =
settings.display.artifactDirectoryModeEnabled ?? false;
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
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
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
@@ -98,7 +98,7 @@ import {
lockThreadRagMode,
useLocalSettings,
} from "@/core/settings";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import { CustomPromptBar } from "./custom-prompt-menu";
import { useThread } from "./messages/context";
@@ -224,6 +224,9 @@ export function InputBox({
const { thread, isMock } = useThread();
const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const [followups, setFollowups] = useState<string[]>([]);
const [followupsHidden, setFollowupsHidden] = useState(false);
@@ -830,7 +833,7 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>}
{!frontendRagActive && !hideContextTools && <Tooltip
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -56,7 +56,6 @@ import {
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
applyInlineCitations,
@@ -86,11 +85,13 @@ export function MessageGroup({
messages,
isLoading = false,
displaySources = [],
ragModeEnabled = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const [showAbove, setShowAbove] = useState(
@@ -100,11 +101,10 @@ export function MessageGroup({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
@@ -199,7 +199,7 @@ export function MessageGroup({
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
ragModeEnabled={ragModeEnabled}
/>
),
)}
@@ -211,7 +211,7 @@ export function MessageGroup({
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
ragModeEnabled={ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -31,6 +31,7 @@ import {
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -301,8 +302,12 @@ export function MessageList({
}, [messages]);
const [settings] = useLocalSettings();
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
() =>
messages.some(
@@ -783,6 +788,7 @@ export function MessageList({
messages={[message]}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
ragModeEnabled={ragModeEnabled}
/>,
);
}
@@ -835,6 +841,7 @@ export function MessageList({
messages={group.messages}
isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
ragModeEnabled={ragModeEnabled}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -205,6 +205,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
const cleanupWords =
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
useEffect(() => {
setShortcutDraft(
@@ -845,6 +847,27 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
aria-label="回答速度指示器"
/>
</div>
{isAdmin && (
<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">
管理员配置。开启后,普通问答和 iframe 问答输入区会显示「参考文献」按钮;默认关闭。
</p>
</div>
<Switch
checked={referenceModeEntryEnabled}
disabled={updateCitationDisplay.isPending}
onCheckedChange={(checked) =>
updateCitationDisplay.mutate({
reference_mode_enabled: checked,
})
}
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>
@@ -860,6 +883,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
aria-label="RAG 引用模式"
/>
</div>
)}
{isAdmin && (
<div className="rounded-lg border p-4">
<div className="mb-3">
@@ -6,6 +6,7 @@ export interface PromptPrefixSettings {
enabled: boolean;
switch_label: string;
prompt_prefix: string;
ordinary_qa_markdown_format_enabled: boolean;
}
export interface PluginDomainAgentMappingDto {
@@ -37,6 +38,7 @@ export interface PromptPrefixUpdate {
enabled?: boolean;
switch_label?: string;
prompt_prefix?: string;
ordinary_qa_markdown_format_enabled?: boolean;
}
function fromPluginManagementDto(data: {
@@ -173,10 +175,12 @@ export interface PrivateSquareUpdate {
}
export interface CitationDisplaySettings {
reference_mode_enabled: boolean;
cleanup_words: string[];
}
export interface CitationDisplayUpdate {
reference_mode_enabled?: boolean;
cleanup_words?: string[];
}
+21 -3
View File
@@ -18,7 +18,12 @@ import { useI18n } from "../i18n/hooks";
import { OptType, writeYLog } from "../log/y-log";
import type { FileInMessage } from "../messages/utils";
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 type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -590,6 +595,9 @@ export function useThreadStream({
}: ThreadStreamOptions) {
const { t } = useI18n();
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
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
@@ -1546,7 +1554,7 @@ export function useThreadStream({
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
(referenceModeEntryEnabled && localSettings.display.ragModeEnabled) ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
@@ -1666,7 +1674,17 @@ export function useThreadStream({
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 () => {
+12 -2
View File
@@ -39,8 +39,14 @@ import {
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { stopActiveThreadBeforeClarificationReply, useThreadStream } from "@/core/threads/hooks";
import {
useCitationDisplaySettings,
usePluginManagementSettings,
} from "@/core/system-settings/hooks";
import {
stopActiveThreadBeforeClarificationReply,
useThreadStream,
} from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -383,6 +389,9 @@ export default function IframeChatPage() {
}, [forceDefaultAssistant, pendingIframeSubmit, selectedAgentId]);
const ragModeLocked = isThreadRagModeLocked(threadId);
const { data: citationDisplay } = useCitationDisplaySettings();
const referenceModeEntryEnabled =
citationDisplay?.reference_mode_enabled ?? false;
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const contextCompressionEnabled =
@@ -529,6 +538,7 @@ export default function IframeChatPage() {
memoryInjectionEnabled: Boolean(
settings.context.memory_injection_enabled ?? true,
),
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
@@ -46,6 +46,8 @@ export default function PromptPrefixSettingsPage({
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
const [ordinaryQaMarkdownFormatEnabled, setOrdinaryQaMarkdownFormatEnabled] =
useState(false);
// Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click
@@ -57,6 +59,9 @@ export default function PromptPrefixSettingsPage({
setEnabled(data.enabled);
setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix);
setOrdinaryQaMarkdownFormatEnabled(
data.ordinary_qa_markdown_format_enabled === true,
);
initializedRef.current = true;
}, [data]);
@@ -79,6 +84,8 @@ export default function PromptPrefixSettingsPage({
enabled,
switch_label: switchLabel,
prompt_prefix: promptPrefix,
ordinary_qa_markdown_format_enabled:
ordinaryQaMarkdownFormatEnabled,
});
toast.success("已保存",darkToastOpts.success);
} catch (e) {
@@ -95,10 +102,9 @@ export default function PromptPrefixSettingsPage({
}
>
<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">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
管理员可在此配置问答前缀和普通问答的固定 Markdown 格式提示词。
</p>
</div>
@@ -147,6 +153,21 @@ export default function PromptPrefixSettingsPage({
/>
</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">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
@@ -9,6 +9,7 @@ test("includes the context compression toggle in the iframe chat toolbar", () =>
agentSelected: false,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: 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", () => {
const buttons = buildIframeModeButtons({
agentSelected: true,
writingModeEnabled: false,
memoryInjectionEnabled: true,
referenceModeEntryEnabled: true,
ragModeEnabled: false,
ragModeLocked: false,
contextCompressionEnabled: false,
@@ -15,6 +15,7 @@ type BuildIframeModeButtonsInput = {
agentSelected: boolean;
writingModeEnabled: boolean;
memoryInjectionEnabled: boolean;
referenceModeEntryEnabled: boolean;
ragModeEnabled: boolean;
ragModeLocked: boolean;
contextCompressionEnabled: boolean;
@@ -25,12 +26,13 @@ export function buildIframeModeButtons({
agentSelected,
writingModeEnabled,
memoryInjectionEnabled,
referenceModeEntryEnabled,
ragModeEnabled,
ragModeLocked,
contextCompressionEnabled,
proModeEnabled,
}: BuildIframeModeButtonsInput): IframeModeButtonState[] {
return [
const buttons: IframeModeButtonState[] = [
{
key: "writing",
label: "写作",
@@ -63,4 +65,7 @@ export function buildIframeModeButtons({
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 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 |
| **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`. |
| **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 |
@@ -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.
### 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 按类型聚合)
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
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
@@ -42,12 +42,17 @@ class PromptPrefixResponse(BaseModel):
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
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")
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):
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
ordinary_qa_markdown_format_enabled: bool | None = None
class PluginDomainAgentMappingItem(BaseModel):
@@ -82,10 +87,12 @@ class PrivateSquareUpdateRequest(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")
class CitationDisplayUpdateRequest(BaseModel):
reference_mode_enabled: bool | None = None
cleanup_words: list[str] | None = None
@@ -110,6 +117,7 @@ def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
enabled=settings.enabled,
switch_label=settings.switch_label,
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)
async def get_citation_display() -> CitationDisplayResponse:
"""Return global citation display cleanup settings."""
"""Return global citation display settings."""
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)
@@ -420,14 +431,22 @@ async def update_citation_display(
body: CitationDisplayUpdateRequest,
request: Request,
) -> CitationDisplayResponse:
"""Update global citation display cleanup settings. Admin only."""
"""Update global citation display settings. Admin only."""
await _require_admin(request)
current = load_system_settings()
words = [str(item).strip() for item in (body.cleanup_words or []) if str(item).strip()]
updated = current.citation_display.model_copy(update={"cleanup_words": words})
fields = body.model_dump(exclude_unset=True)
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}))
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)
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
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.types import Skill, SkillCategory
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 = """
<role>
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_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 ""
)
@@ -153,6 +153,10 @@ class PromptPrefixSettings(BaseModel):
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")
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")
@@ -255,8 +259,12 @@ class AppearanceSettings(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(
default_factory=list,
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:
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_agent_soul", 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 notebook_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
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 deerflow.agents.lead_agent import prompt as prompt_module
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()
assert settings.prompt_prefix.enabled is False
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:
@@ -36,6 +41,7 @@ def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
enabled=True,
switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度",
ordinary_qa_markdown_format_enabled=True,
)
)
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.switch_label == "加入立场前缀"
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: