diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 2a417145..9af9b39d 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -362,14 +362,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ />
diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index bb2b7dcf..724cb7c6 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -1,5 +1,5 @@ import type { Message } from "@langchain/langgraph-sdk"; -import { FileIcon, Loader2Icon } from "lucide-react"; +import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react"; import { memo, useCallback, @@ -25,6 +25,7 @@ import { import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { useChatAskTimes } from "@/core/chat-timestamps"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -36,15 +37,20 @@ import { type FileInMessage, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { + applyInlineCitations, + type RuntimeDisplaySource, +} from "@/core/skill-display/runtime"; import { humanMessagePlugins } from "@/core/streamdown"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; +import { CitationLink } from "../citations/citation-link"; import { CopyButton } from "../copy-button"; - +import { SkillDisplayBlock } from "../skill-display-block"; import { ThreadContext } from "./context"; import { DownloadWordButton } from "./download-word-button"; -import { MarkdownContent } from "./markdown-content"; +import { MarkdownContent, type MarkdownContentProps } from "./markdown-content"; import { MessageTokenUsage } from "./message-token-usage"; /** @@ -76,6 +82,7 @@ export function MessageListItem({ isLoading, threadId, tokenUsageEnabled = false, + displaySources = [], extraRehypePlugins, }: { className?: string; @@ -83,6 +90,7 @@ export function MessageListItem({ isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; }) { const isHuman = message.type === "human"; @@ -101,6 +109,7 @@ export function MessageListItem({ isLoading={isLoading} threadId={threadId} tokenUsageEnabled={tokenUsageEnabled} + displaySources={displaySources} extraRehypePlugins={extraRehypePlugins} /> {!isLoading && ( @@ -162,6 +171,7 @@ function MessageContent_({ isLoading = false, threadId, tokenUsageEnabled = false, + displaySources = [], extraRehypePlugins, }: { className?: string; @@ -169,6 +179,7 @@ function MessageContent_({ isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -188,6 +199,7 @@ function MessageContent_({ () => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins), [stableExtraPlugins, baseRehypePlugins], ); + const askTimes = useChatAskTimes(); const isHuman = message.type === "human"; const resolveArtifact = useArtifactResolver(threadId); const components = useMemo( @@ -196,6 +208,12 @@ function MessageContent_({ ), a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string" && /^citation:/i.test(props.children)) { + return ; + } if (href?.startsWith("/mnt/")) { const url = resolveArtifact(href); return ( @@ -213,6 +231,11 @@ function MessageContent_({ [threadId, resolveArtifact], ); + const messageTime = formatAskedTime( + message.additional_kwargs?.client_ts ?? + (message.id ? askTimes[message.id] : undefined), + ); + const rawContent = extractContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message); @@ -232,8 +255,8 @@ function MessageContent_({ if (isHuman) { return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; } - return rawContent ?? ""; - }, [rawContent, isHuman]); + return applyInlineCitations(rawContent ?? "", displaySources); + }, [rawContent, isHuman, displaySources]); const filesList = files && files.length > 0 ? ( @@ -292,6 +315,7 @@ function MessageContent_({ {messageResponse} )} +
); } @@ -299,22 +323,72 @@ function MessageContent_({ return ( {filesList} - +
+ +
+ {!isLoading && contentToDisplay ? ( + + ) : null}
); } +/** + * 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 ( +
+ + {time.label} +
+ ); +} + /** * Get file extension and check helpers */ @@ -482,3 +556,172 @@ function RichFileCard({ } 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" ? ( + + ) : ( + + ), + )} + {fallbackBlocks.map((group) => ( + + ))} + + ); +} + +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 } { + const pattern = /]*?)\/>/g; + const parts: DisplayPart[] = []; + const placedKeys = new Set(); + 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 = {}; + 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, ""); +} diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index ce10ffb2..33463bf9 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,6 +1,7 @@ +import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Conversation, @@ -8,6 +9,7 @@ import { } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { rememberAskTimes } from "@/core/chat-timestamps"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -20,6 +22,12 @@ import { hasToolCalls, } from "@/core/messages/utils"; 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 { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; @@ -63,7 +71,6 @@ export function MessageList({ onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { - const { t } = useI18n(); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo( () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), @@ -72,6 +79,31 @@ export function MessageList({ const updateSubtask = useUpdateSubtask(); 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 = {}; + 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. // Pending = clarification tool messages that appear after the last human message // (no user reply has been sent for them yet). @@ -190,6 +222,16 @@ export function MessageList({ {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { 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 ( ); @@ -442,6 +485,24 @@ export type SetupAgentApprovalPayload = { 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( message: unknown, ): SetupAgentApprovalPayload | null { diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index 2fca2ddf..ca241545 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -165,7 +165,7 @@ export function WorkspaceNavChatList({ )} - {!showCollapseTrigger && } + {/* {!showCollapseTrigger && } */} ); } diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index e2542bde..0aa7de26 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -7,6 +7,8 @@ import React, { } from 'react'; import { useTheme as useNextTheme } from 'next-themes'; +import { useAppearanceSettings } from '@/core/system-settings/hooks'; + // 主题类型 export type ThemeType = 'dark' | 'light'; @@ -83,6 +85,12 @@ const themes: Record = { }; 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 { if (typeof window === 'undefined') { @@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType { 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 { theme: ThemeType; @@ -112,42 +130,68 @@ interface ThemeProviderProps { export const ThemeProvider: React.FC = ({ children }) => { const { resolvedTheme, setTheme: setNextTheme } = useNextTheme(); 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(() => { setMounted(true); }, []); - // 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色 + // 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。 + // 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作 + // 「用户已选过」,admin 默认就再也注入不进来了。 useEffect(() => { if (!mounted) return; const current = localStorage.getItem(STORAGE_KEY); const legacy = localStorage.getItem('theme'); - if (current !== 'light' && current !== 'dark') { - const fallback = - legacy === 'light' || legacy === 'dark' ? legacy : 'light'; - setNextTheme(fallback); - } else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { - setNextTheme(legacy); + if (current && current !== 'light' && current !== 'dark') { + // Garbage value — wipe it so the boot-sync below can take over. + localStorage.removeItem(STORAGE_KEY); + } + if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { + // 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) { localStorage.removeItem('theme'); } }, [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 - ? resolvedTheme === 'light' - ? 'light' - : 'dark' + ? resolvedTheme === 'dark' + ? 'dark' + : 'light' : readPersistedTheme(); const toggleTheme = () => { setNextTheme(theme === 'dark' ? 'light' : 'dark'); + markExplicitChoice(); }; const setTheme = (newTheme: ThemeType) => { setNextTheme(newTheme); + markExplicitChoice(); }; const themeConfig = themes[theme]; diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index a8c3ff85..d6f3be0c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -28,151 +28,172 @@ import TagsPage from "./TagsPage"; import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNoteListPage from "./LightAppNoteListPage"; 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() { return ( - -
- - } /> + +
+ + } /> - {/* 问答中心 */} - } /> + {/* 问答中心 */} + } /> + {/* 任务管理 */} + }> + {/* XK任务 */} + + } + /> + + } + /> + + {/* 轻应用 */} + } /> + } /> + } /> - {/* 轻应用 */} - } /> - } /> - } /> + {/* 对话 */} + } /> + + + + } + /> - {/* 对话 */} - } /> - - - - } - /> + {/* 智能体管理 */} + } /> + } /> + } + /> + + + + } + /> - {/* 智能体管理 */} - } /> - } /> - } - /> - - - - } - /> + {/* 技能 / 记忆 */} + } /> + } /> + } /> - {/* 技能 / 记忆 */} - } /> - } /> - } /> + {/* 定时任务 */} + } + /> + } /> + } + /> - {/* 定时任务 */} - } - /> - } /> - } - /> - - {/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */} - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - - ) : ( - - ) - } - /> - -
-
- + {/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */} + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> + + ) : ( + + ) + } + /> +
+
+
+
); } diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 8889783d..512dcb7a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import logging import shutil +import time from datetime import datetime from typing import Any 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 ``.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: 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") +# 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: """Lazily restore an agent's on-disk artifacts from its DB record. @@ -262,14 +306,29 @@ def _record_to_response( record: dict[str, Any], *, include_soul: bool = False, + include_extras: bool = True, ) -> 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 + 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: soul = load_agent_soul(record["id"]) or "" - extras = _load_config_extras(record["id"]) - return AgentResponse( id=record["id"], 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]: owned = await store.get_owned(agent_id, user_id) if owned is not None: @@ -349,7 +427,7 @@ async def list_agents( try: store = get_agent_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) if admin: if not _is_admin_user(request): @@ -357,7 +435,14 @@ async def list_agents( agents = await store.list_all() else: 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]) 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() try: store = get_agent_store(request) - await _sync_legacy_agents(store) + await _maybe_sync_legacy_agents(store) user_id = _current_user_id(request) 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: raise except Exception as e: @@ -427,12 +519,15 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse: try: store = get_agent_store(request) - await _sync_legacy_agents(store) + await _maybe_sync_legacy_agents(store) user_id = _current_user_id(request) record = await store.get_visible(agent_id, user_id) if record is None: 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) response.is_favorite = agent_id in favorite_ids 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) - return _record_to_response(record, include_soul=True) + return await _record_to_response_async(record, include_soul=True, include_extras=True) except HTTPException: 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") 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: raise @@ -744,7 +839,7 @@ async def set_agent_featured( try: store = get_agent_store(request) - await _sync_legacy_agents(store) + await _maybe_sync_legacy_agents(store) record = await store.get_any(agent_id) if record is None: 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: 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) - return _record_to_response(updated, include_soul=True) + return await _record_to_response_async(updated, include_soul=True, include_extras=True) except HTTPException: raise except Exception as e: diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index ed68c219..5bda2872 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -58,16 +58,6 @@ models: temperature: 1.0 supports_vision: 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: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 4f14c421..dfcdad56 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -7,10 +7,10 @@ from langchain_core.runnables import RunnableConfig from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware -from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware -from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware -from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware -from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware +from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware +from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware +from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware +from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware from deerflow.agents.middlewares.title_middleware import TitleMiddleware @@ -253,16 +253,16 @@ def _build_middlewares( Returns: List of middleware instances. - """ - resolved_app_config = app_config or get_app_config() - cfg = _get_runtime_config(config) - middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True) + """ + resolved_app_config = app_config or get_app_config() + cfg = _get_runtime_config(config) + 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 - # persisted/displayed message so the user never sees it). - middlewares.append(PromptPrefixMiddleware()) - if cfg.get("rag_mode_enabled", False): - middlewares.append(RagCitationMiddleware()) + # Inject the admin prompt prefix into the model request (kept out of the + # persisted/displayed message so the user never sees it). + middlewares.append(PromptPrefixMiddleware()) + if cfg.get("rag_mode_enabled", False): + middlewares.append(RagCitationMiddleware()) # Add summarization middleware if enabled summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config) @@ -270,7 +270,7 @@ def _build_middlewares( middlewares.append(summarization_middleware) # 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) todo_list_middleware = _create_todo_list_middleware(is_plan_mode) 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) is_bootstrap = cfg.get("is_bootstrap", False) 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_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(): @@ -464,6 +465,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): is_bootstrap=True, app_config=resolved_app_config, memory_injection_enabled=memory_injection_enabled, + rag_mode_enabled=rag_mode_enabled, ), 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), app_config=resolved_app_config, memory_injection_enabled=memory_injection_enabled, + rag_mode_enabled=rag_mode_enabled, is_notebook_mode=is_notebook_mode, notebook_source_ids=notebook_source_ids, ), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 3e7171d7..96dacf1f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -667,17 +667,25 @@ def _get_memory_context(agent_name: str | None = None, *, app_config: AppConfig @lru_cache(maxsize=32) def _get_cached_skills_prompt_section( skill_signature: tuple[tuple[str, str, str, str], ...], + display_signature: tuple[tuple[str, str, str], ...], available_skills_key: tuple[str, ...] | None, container_base_path: str, skill_evolution_section: 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] skills_list = "" if filtered: - skill_items = "\n".join( - f" \n {name}\n {description} {_skill_mutability_label(category)}\n {location}\n " - for name, description, category, location in filtered - ) + items = [] + 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 {custom_instruction}" + items.append( + f" \n {name}\n {description} {_skill_mutability_label(category)}\n {location}{display_hint}\n " + ) + skill_items = "\n".join(items) skills_list = f"\n{skill_items}\n" # Restricted mode: agent has an explicit skill allowlist. @@ -693,6 +701,19 @@ def _get_cached_skills_prompt_section( else: scope_note = "" + display_pattern = ( + """ +**Skill Result Display Pattern:** +- If a skill has ``, 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 ``, summarize the returned items as a concise list and keep item order stable. +- If a skill has ``, summarize the result and output `` where the frontend should render the real table. Do not hand-write the table rows yourself. +- If a skill has ``, summarize the result and output `` where the frontend should render the cards. +- If a skill has ``, follow the custom display instruction in the `` tag when presenting the skill result. +""" + if display_signature + else "" + ) + return f""" 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} @@ -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 4. Load referenced resources only when needed during execution 5. Follow the skill's instructions precisely +{display_pattern} {location_hint}{evolution_block} {skills_list} """ -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.""" 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 "" 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 if not skill_signature and available_key is not None: return "" 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: @@ -868,6 +914,7 @@ def apply_prompt_template( is_bootstrap: bool = False, app_config: AppConfig | None = None, memory_injection_enabled: bool = True, + rag_mode_enabled: bool = False, is_notebook_mode: bool = False, notebook_source_ids: list[int] | None = None, ) -> str: @@ -902,7 +949,11 @@ def apply_prompt_template( bootstrap_section = _build_bootstrap_section() if is_bootstrap else "" # 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) 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). network_policy_section = _build_network_policy_section() + rag_mode_section = ( + """ + +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 `` where the table should appear. The frontend will render the real tool result as a stable table. +- For `list` or `card`, summarize and place `` or `` where the structured result should appear. +- If no display adapter exists, default to citation mode with numbered citations. + +""" + if rag_mode_enabled + else "" + ) # Format the prompt with dynamic skills and memory prompt = SYSTEM_PROMPT_TEMPLATE.format( @@ -957,7 +1023,7 @@ Writing mode is enabled for this run. acp_section=acp_and_mounts_section, ) - prompt = prompt + f"\n{datetime.now().strftime('%Y-%m-%d, %A')}" + prompt = prompt + rag_mode_section + f"\n{datetime.now().strftime('%Y-%m-%d, %A')}" if is_notebook_mode: source_ids = notebook_source_ids or []