Merge origin/master into xuhd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xuhdandClaude Sonnet 4.6 committed 2026-05-28 18:31:16 +08:00
commit ebe1678b30
10 files changed
+731 -208

No files matched your search

@@ -362,14 +362,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
/> />
<ResizablePanel <ResizablePanel
className={cn( className={cn(
"bg-background transition-all duration-300 ease-in-out", "bg-[#fffdfa] transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0", !sidePanelOpen && "opacity-0",
)} )}
id="artifacts" id="artifacts"
> >
<div <div
className={cn( className={cn(
"h-full bg-background p-4 transition-transform duration-300 ease-in-out", "h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full", sidePanelOpen ? "translate-x-0" : "translate-x-full",
)} )}
> >
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react"; import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import { import {
memo, memo,
useCallback, useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -36,15 +37,20 @@ import {
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context"; import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button"; import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage"; import { MessageTokenUsage } from "./message-token-usage";
/** /**
@@ -76,6 +82,7 @@ export function MessageListItem({
isLoading, isLoading,
threadId, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins, extraRehypePlugins,
}: { }: {
className?: string; className?: string;
@@ -83,6 +90,7 @@ export function MessageListItem({
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === "human";
@@ -101,6 +109,7 @@ export function MessageListItem({
isLoading={isLoading} isLoading={isLoading}
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins} extraRehypePlugins={extraRehypePlugins}
/> />
{!isLoading && ( {!isLoading && (
@@ -162,6 +171,7 @@ function MessageContent_({
isLoading = false, isLoading = false,
threadId, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins, extraRehypePlugins,
}: { }: {
className?: string; className?: string;
@@ -169,6 +179,7 @@ function MessageContent_({
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>; extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@@ -188,6 +199,7 @@ function MessageContent_({
() => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins), () => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins),
[stableExtraPlugins, baseRehypePlugins], [stableExtraPlugins, baseRehypePlugins],
); );
const askTimes = useChatAskTimes();
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo( const components = useMemo(
@@ -196,6 +208,12 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" /> <MessageImage {...props} threadId={threadId} maxWidth="90%" />
), ),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) { if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href); const url = resolveArtifact(href);
return ( return (
@@ -213,6 +231,11 @@ function MessageContent_({
[threadId, resolveArtifact], [threadId, resolveArtifact],
); );
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message);
@@ -232,8 +255,8 @@ function MessageContent_({
if (isHuman) { if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
} }
return rawContent ?? ""; return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman]); }, [rawContent, isHuman, displaySources]);
const filesList = const filesList =
files && files.length > 0 ? ( files && files.length > 0 ? (
@@ -292,6 +315,7 @@ function MessageContent_({
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}
<MessageTimeStamp time={messageTime} align="right" />
</div> </div>
); );
} }
@@ -299,22 +323,72 @@ function MessageContent_({
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
<MarkdownContent <div data-reference-batch-id={message.id} className="contents">
content={contentToDisplay} <AssistantDisplayContent
isLoading={isLoading} content={contentToDisplay}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} displaySources={displaySources}
className="my-3" isLoading={isLoading}
components={components} renderSkillDisplayBlocks={false}
/> rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage <MessageTokenUsage
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={isLoading} isLoading={isLoading}
message={message} message={message}
/> />
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent> </AIElementMessageContent>
); );
} }
/**
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
* full localized string for the hover title. Returns ``null`` when no/invalid
* timestamp is present.
*/
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return null;
const date = new Date(clientTs);
if (Number.isNaN(date.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
const label =
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
return { label, full: date.toLocaleString() };
}
/**
* Small date+time label shown under a message. ``align`` controls the corner:
* ``right`` for the user's own question, ``left`` for the model's answer.
*/
function MessageTimeStamp({
time,
align = "right",
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
}) {
if (!time) return null;
return (
<div
className={cn(
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
)}
title={time.full}
>
<ClockIcon className="size-3 shrink-0" />
<span>{time.label}</span>
</div>
);
}
/** /**
* Get file extension and check helpers * Get file extension and check helpers
*/ */
@@ -482,3 +556,172 @@ function RichFileCard({
} }
const MessageContent = memo(MessageContent_); const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
renderSkillDisplayBlocks,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() =>
splitSkillDisplayPlaceholders(
content,
displaySources,
renderSkillDisplayBlocks,
),
[content, displaySources, renderSkillDisplayBlocks],
);
const fallbackBlocks = useMemo(() => {
if (!renderSkillDisplayBlocks) {
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
components={components}
/>
) : (
<SkillDisplayBlock
key={`display-${index}`}
mode={part.mode}
sources={part.sources}
/>
),
)}
{fallbackBlocks.map((group) => (
<SkillDisplayBlock
key={`fallback-${group.key}`}
mode={group.mode}
sources={group.sources}
/>
))}
</>
);
}
type DisplayPart =
| { type: "markdown"; content: string }
| {
type: "display";
key: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
};
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
renderSkillDisplayBlocks: boolean,
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
const placedKeys = new Set<string>();
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
placedKeys.add(group.key);
}
if (group && renderSkillDisplayBlocks) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
}
cursor = start + match[0].length;
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
return false;
}
if (!tool) {
return true;
}
const left = normalizeName(group.skillName);
const right = normalizeName(tool);
return left === right || left.includes(right) || right.includes(left);
});
}
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
const groups = new Map<
string,
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
return [...groups.values()];
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -1,6 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Conversation, Conversation,
@@ -8,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -20,6 +22,12 @@ import {
hasToolCalls, hasToolCalls,
} 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 { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks"; import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
@@ -63,7 +71,6 @@ export function MessageList({
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) { }) {
const { t } = useI18n();
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo( const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
@@ -72,6 +79,31 @@ export function MessageList({
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
// seen, in a single batched commit. Questions sent this session already carry
// an accurate time (see useThreadStream); this also covers assistant answers
// and messages loaded from history. First-write-wins keeps the earliest time.
useEffect(() => {
const now = new Date().toISOString();
const entries: Record<string, string> = {};
for (const message of messages) {
if (message.type !== "human" && message.type !== "ai") continue;
if (!message.id || message.id.startsWith("opt-")) continue;
if (typeof message.additional_kwargs?.client_ts === "string") continue;
entries[message.id] = now;
}
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
}, [messages]);
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills],
);
// Track which clarification messages are still awaiting an answer. // Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message // Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet). // (no user reply has been sent for them yet).
@@ -190,6 +222,16 @@ export function MessageList({
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return ( return (
<MessageListItem <MessageListItem
key={`${group.id}/${msg.id}`} key={`${group.id}/${msg.id}`}
@@ -197,6 +239,7 @@ export function MessageList({
isLoading={thread.isLoading} isLoading={thread.isLoading}
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins} extraRehypePlugins={extraRehypePlugins}
/> />
); );
@@ -442,6 +485,24 @@ export type SetupAgentApprovalPayload = {
skills?: string[]; skills?: string[];
}; };
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
): string | undefined {
const beforeIndex =
beforeMessageId === undefined
? messages.length
: messages.findIndex((message) => message.id === beforeMessageId);
const end = beforeIndex < 0 ? messages.length : beforeIndex;
for (let index = end - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "human") {
return message.id;
}
}
return undefined;
}
function getSetupAgentApprovalPayload( function getSetupAgentApprovalPayload(
message: unknown, message: unknown,
): SetupAgentApprovalPayload | null { ): SetupAgentApprovalPayload | null {
@@ -165,7 +165,7 @@ export function WorkspaceNavChatList({
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
{!showCollapseTrigger && <WorkspaceNavStudio />} {/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
</> </>
); );
} }
+55 -11
View File
@@ -7,6 +7,8 @@ import React, {
} from 'react'; } from 'react';
import { useTheme as useNextTheme } from 'next-themes'; import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型 // 主题类型
export type ThemeType = 'dark' | 'light'; export type ThemeType = 'dark' | 'light';
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
}; };
const STORAGE_KEY = 'strategy-theme'; const STORAGE_KEY = 'strategy-theme';
// Marker that the user has explicitly toggled the theme (via the appearance
// settings page or any future toggle UI). When this flag is absent we treat
// any value stored in ``strategy-theme`` as one we put there ourselves while
// syncing the admin's default — so we'll re-sync on every boot, picking up
// admin changes. When the flag is present we never override the user.
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
function readPersistedTheme(): ThemeType { function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
return saved === 'dark' ? 'dark' : 'light'; return saved === 'dark' ? 'dark' : 'light';
} }
function hasExplicitChoice(): boolean {
if (typeof window === 'undefined') return false;
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
}
function markExplicitChoice(): void {
if (typeof window === 'undefined') return;
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
}
// 主题上下文 // 主题上下文
interface ThemeContextType { interface ThemeContextType {
theme: ThemeType; theme: ThemeType;
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme(); const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Admin-configured global default. If the user hasn't explicitly chosen
// a theme yet, this value drives the rendered theme — and re-syncs on
// every mount so an admin change propagates without users clearing
// localStorage by hand.
const { data: appearance } = useAppearanceSettings();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色 // 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY); const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme'); const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') { if (current && current !== 'light' && current !== 'dark') {
const fallback = // Garbage value — wipe it so the boot-sync below can take over.
legacy === 'light' || legacy === 'dark' ? legacy : 'light'; localStorage.removeItem(STORAGE_KEY);
setNextTheme(fallback); }
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy); // Migrate the legacy key as the user's explicit choice (it was set
// long enough ago that we should preserve their pick rather than
// override with admin default).
setNextTheme(legacy);
markExplicitChoice();
} }
if (legacy) { if (legacy) {
localStorage.removeItem('theme'); localStorage.removeItem('theme');
} }
}, [mounted, setNextTheme]); }, [mounted, setNextTheme]);
// Boot-time sync: when the user has never picked a theme themselves,
// apply whatever the admin configured. Re-runs whenever the admin
// setting changes (so a logged-in user sees the new default after
// refreshing without having to clear cookies).
useEffect(() => {
if (!mounted) return;
if (hasExplicitChoice()) return;
const adminDefault = appearance?.default_theme;
if (!adminDefault) return;
if (resolvedTheme !== adminDefault) {
setNextTheme(adminDefault);
}
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
const theme: ThemeType = mounted const theme: ThemeType = mounted
? resolvedTheme === 'light' ? resolvedTheme === 'dark'
? 'light' ? 'dark'
: 'dark' : 'light'
: readPersistedTheme(); : readPersistedTheme();
const toggleTheme = () => { const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark'); setNextTheme(theme === 'dark' ? 'light' : 'dark');
markExplicitChoice();
}; };
const setTheme = (newTheme: ThemeType) => { const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme); setNextTheme(newTheme);
markExplicitChoice();
}; };
const themeConfig = themes[theme]; const themeConfig = themes[theme];
+156 -135
View File
@@ -28,151 +28,172 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <RouteBaseProvider value="/page/strategy">
<SidebarProvider> <SidebarProvider>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<Routes> <Routes>
<Route index element={<Navigate to="qa/general" replace />} /> <Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */} {/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} /> <Route path="qa/general" element={<GeneralQA />} />
{/* 任务管理 */}
<Route path="task-management" element={<TaskManagement />}>
{/* XK任务 */}
<Route
path="xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
</Route>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 轻应用 */} {/* 对话 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} /> <Route path="chats" element={<ChatsPage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} /> path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */} {/* 智能体管理 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="agents" element={<AgentsPage />} />
<Route <Route path="agents/new" element={<NewAgentPage />} />
path="chats/:thread_id" <Route
element={ path="agents/:agent_id/chats"
<ChatRuntime> element={<Navigate to="/page/strategy/agents" replace />}
<ChatPage /> />
</ChatRuntime> <Route
} path="agents/:agent_id/chats/:thread_id"
/> element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */} {/* 技能 / 记忆 */}
<Route path="agents" element={<AgentsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="agents/new" element={<NewAgentPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route <Route path="memory" element={<MemoryPage />} />
path="agents/:agent_id/chats"
element={<Navigate to="/page/strategy/agents" replace />}
/>
<Route
path="agents/:agent_id/chats/:thread_id"
element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 技能 / 记忆 */} {/* 定时任务 */}
<Route path="skills" element={<SkillsPage />} /> <Route
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> path="scheduled"
<Route path="memory" element={<MemoryPage />} /> element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
/>
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
<Route
path="scheduled/runs/:run_id"
element={<ScheduledTaskRunDetailPage />}
/>
{/* 定时任务 */} {/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route <Route
path="scheduled" path="settings/recommended-questions"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />} element={
/> getStoredAuth()?.system_role === "admin" ? (
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} /> <RecommendedQuestionsPage />
<Route ) : (
path="scheduled/runs/:run_id" <Navigate to="/page/workspace/chats/new" replace />
element={<ScheduledTaskRunDetailPage />} )
/> }
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */} <Route
<Route path="settings/prompt-prefix"
path="settings/recommended-questions" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PromptPrefixSettingsPage />
<RecommendedQuestionsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/private-square"
path="settings/prompt-prefix" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PrivateSquareSettingsPage />
<PromptPrefixSettingsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/llm-metrics"
path="settings/private-square" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <LlmMetricsPage />
<PrivateSquareSettingsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/users"
path="settings/llm-metrics" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <UsersPage />
<LlmMetricsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/scheduled-tasks"
path="admin/users" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminScheduledTasksPage />
<UsersPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/user-activity"
path="admin/scheduled-tasks" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminUserActivityPage />
<AdminScheduledTasksPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/tags"
path="admin/user-activity" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <TagsPage />
<AdminUserActivityPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> </Routes>
<Route </div>
path="admin/tags" </SidebarProvider>
element={ <Toaster position="top-center" />
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</div>
</SidebarProvider>
<Toaster position="top-center" />
</RouteBaseProvider> </RouteBaseProvider>
); );
} }
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import shutil import shutil
import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -207,6 +208,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
This preserves old deployments where agents already existed under This preserves old deployments where agents already existed under
``.deer-flow/agents/{name}`` before the agents table was introduced. ``.deer-flow/agents/{name}`` before the agents table was introduced.
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
handlers as a safety net for environments that bypassed the lifespan hook.
""" """
try: try:
for agent_cfg in list_custom_agents(): for agent_cfg in list_custom_agents():
@@ -225,6 +231,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
logger.exception("Failed to sync legacy folder-backed agents") logger.exception("Failed to sync legacy folder-backed agents")
# Throttle the per-request legacy-sync fallback. The real sync runs once at
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
# every hit, which scanned the filesystem + did N DB upserts per agent each
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
# — long enough to be effectively "startup-only" in steady state, short enough
# that a freshly dropped agent directory gets picked up on the next bucket.
_LEGACY_SYNC_TTL_SECONDS = 3600.0
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
hook. Inside the TTL window we don't even take the lock; outside it we
serialize callers so a thundering herd doesn't fan out into N filesystem
scans + N*M DB queries.
"""
now = time.monotonic()
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
lock = _legacy_sync_state["lock"]
if lock is None:
lock = asyncio.Lock()
_legacy_sync_state["lock"] = lock
async with lock:
# Re-check under the lock — a sibling coroutine may have just refreshed.
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
return
await _sync_legacy_agents(store)
_legacy_sync_state["last_at"] = time.monotonic()
def _mark_legacy_sync_fresh() -> None:
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
_legacy_sync_state["last_at"] = time.monotonic()
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None: def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
"""Lazily restore an agent's on-disk artifacts from its DB record. """Lazily restore an agent's on-disk artifacts from its DB record.
@@ -262,14 +306,29 @@ def _record_to_response(
record: dict[str, Any], record: dict[str, Any],
*, *,
include_soul: bool = False, include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse: ) -> AgentResponse:
_ensure_agent_dir_from_record(record) """Convert a DB row to an API response.
Args:
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
in list endpoints — the frontend (gallery / recommend / selector)
never reads ``soul``, only the edit dialog does. Skipping this
avoids one synchronous file read per agent.
include_extras: Self-heal a missing agent dir and read deprecated
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
False in list endpoints to skip a stat + yaml parse per agent —
those fields are unused by current frontends and the dir restore
can happen lazily when the agent is actually opened.
"""
soul: str | None = None soul: str | None = None
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
if include_extras:
_ensure_agent_dir_from_record(record)
extras = _load_config_extras(record["id"])
if include_soul: if include_soul:
soul = load_agent_soul(record["id"]) or "" soul = load_agent_soul(record["id"]) or ""
extras = _load_config_extras(record["id"])
return AgentResponse( return AgentResponse(
id=record["id"], id=record["id"],
name=record["name"], name=record["name"],
@@ -288,6 +347,25 @@ def _record_to_response(
) )
async def _record_to_response_async(
record: dict[str, Any],
*,
include_soul: bool = False,
include_extras: bool = True,
) -> AgentResponse:
"""Async wrapper: when we actually touch disk, hop to a worker thread so
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
this overhead and stay on the calling task."""
if not include_soul and not include_extras:
return _record_to_response(record, include_soul=False, include_extras=False)
return await asyncio.to_thread(
_record_to_response,
record,
include_soul=include_soul,
include_extras=include_extras,
)
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]: async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
owned = await store.get_owned(agent_id, user_id) owned = await store.get_owned(agent_id, user_id)
if owned is not None: if owned is not None:
@@ -349,7 +427,7 @@ async def list_agents(
try: try:
store = get_agent_store(request) store = get_agent_store(request)
tag_store: TagStore = get_tag_store(request) tag_store: TagStore = get_tag_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
if admin: if admin:
if not _is_admin_user(request): if not _is_admin_user(request):
@@ -357,7 +435,14 @@ async def list_agents(
agents = await store.list_all() agents = await store.list_all()
else: else:
agents = await store.list_visible(user_id) agents = await store.list_visible(user_id)
responses = [_record_to_response(a, include_soul=True) for a in agents] # List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
# parse, and dir self-heal — none of those feed UI fields the gallery /
# recommend dialog / selector actually render. Detail endpoints (single
# agent + create/update responses) still load them.
responses = [
_record_to_response(a, include_soul=False, include_extras=False)
for a in agents
]
assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
@@ -403,11 +488,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
_require_agents_api_enabled() _require_agents_api_enabled()
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
records = await store.list_featured(user_id) records = await store.list_featured(user_id)
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records]) # Selector dropdown only renders name/description/featured_order — same
# rationale as list_agents: skip soul + extras + self-heal.
return AgentSelectorResponse(
agents=[
_record_to_response(r, include_soul=False, include_extras=False)
for r in records
]
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -427,12 +519,15 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
user_id = _current_user_id(request) user_id = _current_user_id(request)
record = await store.get_visible(agent_id, user_id) record = await store.get_visible(agent_id, user_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
response = _record_to_response(record, include_soul=True) # Detail view legitimately reads SOUL.md + config.yaml — hop to a
# worker thread so the asyncio loop stays responsive under concurrent
# detail fetches.
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
favorite_ids = await store.list_favorite_ids(user_id) favorite_ids = await store.list_favorite_ids(user_id)
response.is_favorite = agent_id in favorite_ids response.is_favorite = agent_id in favorite_ids
return response return response
@@ -488,7 +583,7 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
} }
) )
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir) logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
return _record_to_response(record, include_soul=True) return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
@@ -569,7 +664,7 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Updated agent '%s' (%s)", record["name"], agent_id) logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
return _record_to_response(record, include_soul=True) return await _record_to_response_async(record, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
@@ -744,7 +839,7 @@ async def set_agent_featured(
try: try:
store = get_agent_store(request) store = get_agent_store(request)
await _sync_legacy_agents(store) await _maybe_sync_legacy_agents(store)
record = await store.get_any(agent_id) record = await store.get_any(agent_id)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -756,7 +851,7 @@ async def set_agent_featured(
if updated is None: if updated is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order) logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
return _record_to_response(updated, include_soul=True) return await _record_to_response_async(updated, include_soul=True, include_extras=True)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -58,16 +58,6 @@ models:
temperature: 1.0 temperature: 1.0
supports_vision: true supports_vision: true
supports_thinking: true supports_thinking: true
- name: minimax-m2.5
display_name: MiniMax M2.5
use: langchain_openai:ChatOpenAI
model: MiniMax-M2.5
api_key: sk-cp-mrsAGtzDpUl0TpOBPVoiRG_pWF1kDDN9_mDAnONBs6HIPcNUexSKtSbx2QOxfKBvJT6Xw6hDTV6hdkJYFWfNA8UtHQ08TiGJiosdRoW6q1sGb6Cb7D85NHk
base_url: https://api.minimaxi.com/v1
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: false
title: title:
enabled: true enabled: true
@@ -7,10 +7,10 @@ from langchain_core.runnables import RunnableConfig
from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -253,16 +253,16 @@ def _build_middlewares(
Returns: Returns:
List of middleware instances. List of middleware instances.
""" """
resolved_app_config = app_config or get_app_config() resolved_app_config = app_config or get_app_config()
cfg = _get_runtime_config(config) cfg = _get_runtime_config(config)
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True) middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
# Inject the admin prompt prefix into the model request (kept out of the # Inject the admin prompt prefix into the model request (kept out of the
# persisted/displayed message so the user never sees it). # persisted/displayed message so the user never sees it).
middlewares.append(PromptPrefixMiddleware()) middlewares.append(PromptPrefixMiddleware())
if cfg.get("rag_mode_enabled", False): if cfg.get("rag_mode_enabled", False):
middlewares.append(RagCitationMiddleware()) middlewares.append(RagCitationMiddleware())
# Add summarization middleware if enabled # Add summarization middleware if enabled
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config) summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
@@ -270,7 +270,7 @@ def _build_middlewares(
middlewares.append(summarization_middleware) middlewares.append(summarization_middleware)
# Add TodoList middleware if plan mode is enabled # Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False) is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode) todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None: if todo_list_middleware is not None:
@@ -374,6 +374,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False) is_bootstrap = cfg.get("is_bootstrap", False)
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True)) memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False)) writing_mode = bool(cfg.get("writing_mode", False))
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip(): if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
@@ -464,6 +465,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_bootstrap=True, is_bootstrap=True,
app_config=resolved_app_config, app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled, memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
), ),
state_schema=ThreadState, state_schema=ThreadState,
) )
@@ -498,6 +500,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=(set(agent_config.skills or []) if agent_config else None), available_skills=(set(agent_config.skills or []) if agent_config else None),
app_config=resolved_app_config, app_config=resolved_app_config,
memory_injection_enabled=memory_injection_enabled, memory_injection_enabled=memory_injection_enabled,
rag_mode_enabled=rag_mode_enabled,
is_notebook_mode=is_notebook_mode, is_notebook_mode=is_notebook_mode,
notebook_source_ids=notebook_source_ids, notebook_source_ids=notebook_source_ids,
), ),
@@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig
@lru_cache(maxsize=32) @lru_cache(maxsize=32)
def _get_cached_skills_prompt_section( def _get_cached_skills_prompt_section(
skill_signature: tuple[tuple[str, str, str, str], ...], skill_signature: tuple[tuple[str, str, str, str], ...],
display_signature: tuple[tuple[str, str, str], ...],
available_skills_key: tuple[str, ...] | None, available_skills_key: tuple[str, ...] | None,
container_base_path: str, container_base_path: str,
skill_evolution_section: str, skill_evolution_section: str,
) -> str: ) -> str:
display_by_skill = {name: (mode, custom_instruction) for name, mode, custom_instruction in display_signature}
filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key] filtered = [(name, description, category, location) for name, description, category, location in skill_signature if available_skills_key is None or name in available_skills_key]
skills_list = "" skills_list = ""
if filtered: if filtered:
skill_items = "\n".join( items = []
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>\n </skill>" for name, description, category, location in filtered:
for name, description, category, location in filtered display_mode, custom_instruction = display_by_skill.get(name, ("", ""))
) display_hint = ""
if display_mode:
display_hint = f"\n <display mode=\"{display_mode}\">{custom_instruction}</display>"
items.append(
f" <skill>\n <name>{name}</name>\n <description>{description} {_skill_mutability_label(category)}</description>\n <location>{location}</location>{display_hint}\n </skill>"
)
skill_items = "\n".join(items)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>" skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
# Restricted mode: agent has an explicit skill allowlist. # Restricted mode: agent has an explicit skill allowlist.
@@ -693,6 +701,19 @@ def _get_cached_skills_prompt_section(
else: else:
scope_note = "" scope_note = ""
display_pattern = (
"""
**Skill Result Display Pattern:**
- If a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
)
return f"""<skill_system> return f"""<skill_system>
You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources. You have access to skills that provide optimized workflows for specific tasks. Each skill contains best practices, frameworks, and references to additional resources.
{scope_note} {scope_note}
@@ -702,13 +723,19 @@ You have access to skills that provide optimized workflows for specific tasks. E
3. The skill file contains references to external resources under the same folder 3. The skill file contains references to external resources under the same folder
4. Load referenced resources only when needed during execution 4. Load referenced resources only when needed during execution
5. Follow the skill's instructions precisely 5. Follow the skill's instructions precisely
{display_pattern}
{location_hint}{evolution_block} {location_hint}{evolution_block}
{skills_list} {skills_list}
</skill_system>""" </skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str: def get_skills_prompt_section(
available_skills: set[str] | None = None,
*,
app_config: AppConfig | None = None,
display_enabled: bool = False,
) -> str:
"""Generate the skills prompt section with available skills list.""" """Generate the skills prompt section with available skills list."""
skills = _get_enabled_skills_for_config(app_config) skills = _get_enabled_skills_for_config(app_config)
@@ -739,11 +766,30 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
return "" return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills) skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
display_signature: tuple[tuple[str, str, str], ...] = ()
if display_enabled:
try:
from deerflow.config.extensions_config import get_extensions_config
extensions = get_extensions_config()
display_signature = tuple(
(
name,
skill_config.display.mode,
skill_config.display.custom_instruction,
)
for name, skill_config in sorted(extensions.skills.items())
if skill_config.display is not None
and skill_config.display.enabled
and skill_config.display.mode != "none"
)
except Exception:
logger.debug("Failed to load skill display configs for prompt injection", exc_info=True)
available_key = tuple(sorted(available_skills)) if available_skills is not None else None available_key = tuple(sorted(available_skills)) if available_skills is not None else None
if not skill_signature and available_key is not None: if not skill_signature and available_key is not None:
return "" return ""
skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled) skill_evolution_section = _build_skill_evolution_section(effective_skill_evolution_enabled)
return _get_cached_skills_prompt_section(skill_signature, available_key, container_base_path, skill_evolution_section) return _get_cached_skills_prompt_section(skill_signature, display_signature, available_key, container_base_path, skill_evolution_section)
def get_agent_soul(agent_id: str | None) -> str: def get_agent_soul(agent_id: str | None) -> str:
@@ -868,6 +914,7 @@ def apply_prompt_template(
is_bootstrap: bool = False, is_bootstrap: bool = False,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
memory_injection_enabled: bool = True, memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
is_notebook_mode: bool = False, is_notebook_mode: bool = False,
notebook_source_ids: list[int] | None = None, notebook_source_ids: list[int] | None = None,
) -> str: ) -> str:
@@ -902,7 +949,11 @@ def apply_prompt_template(
bootstrap_section = _build_bootstrap_section() if is_bootstrap else "" bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
# Get skills section # Get skills section
skills_section = get_skills_prompt_section(available_skills, app_config=app_config) skills_section = get_skills_prompt_section(
available_skills,
app_config=app_config,
display_enabled=rag_mode_enabled,
)
# Get deferred tools section (tool_search) # Get deferred tools section (tool_search)
deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config)
@@ -940,6 +991,21 @@ Writing mode is enabled for this run.
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set). # Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
network_policy_section = _build_network_policy_section() network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If the skill has a display adapter, follow that adapter first.
- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items.
- For `table`, do not hand-write the table. Summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the table should appear. The frontend will render the real tool result as a stable table.
- For `list` or `card`, summarize and place `<skill-display tool="SKILL_OR_TOOL_NAME" mode="list" />` or `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the structured result should appear.
- If no display adapter exists, default to citation mode with numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
# Format the prompt with dynamic skills and memory # Format the prompt with dynamic skills and memory
prompt = SYSTEM_PROMPT_TEMPLATE.format( prompt = SYSTEM_PROMPT_TEMPLATE.format(
@@ -957,7 +1023,7 @@ Writing mode is enabled for this run.
acp_section=acp_and_mounts_section, acp_section=acp_and_mounts_section,
) )
prompt = prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>" prompt = prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
if is_notebook_mode: if is_notebook_mode:
source_ids = notebook_source_ids or [] source_ids = notebook_source_ids or []