新增 RAG 引用展示模式:支持技能结果按编号引用/列表/表格渲染

- 前端新增 skill-display 适配层(runtime/display-adapter)与可视化适配面板,
  支持从技能返回 JSON 自动识别字段并配置 citation/list/table/card 展示
- 正文 [[N]] 编号渲染为可点击徽章,点击不跳 URL 而是定位到右侧参考文献面板;
  参考卡片样式重做(圆形编号、悬停高亮、过滤掉 "result" 占位类型)
- bash 工具调用按命令中的 skills/{public,custom}/<name>/ 路径反查展示适配,
  解决调用名为 "bash" 时无法匹配到具体技能的问题
- 工具结果 JSON 解析放宽:能从 stderr/exit code 噪声尾巴里抢救出 JSON 主体
- 展示适配自动识别接入 LLM:读 SKILL.md 让模型推断真实返回结构,失败回退 mock
- 展示适配面板修复保存回显:保存后表单跟随服务端最新值刷新,样例文本不再被抹掉
- 设置页加 "RAG 引用模式" 开关(默认开),关联 lead-agent prompt 注入引用规则
This commit is contained in:
吉鑫升 committed 2026-05-25 18:19:52 +08:00
1 parent 9a63a4b7ca
commit 8892637b97
26 files changed
+2732 -470

No files matched your search

@@ -1,183 +1,349 @@
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { useThread } from "../messages/context";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
threadId,
}) => {
const { thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
deselect,
selectedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
deselect();
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
// selectedArtifact &&
// !thread.values.artifacts?.includes(selectedArtifact)
// ) {
// deselect();
// }
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const resizableIdBase = useMemo(() => {
const raw = pathname
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return raw.length > 0 ? raw : `thread-${threadId}`;
}, [pathname, threadId]);
useEffect(() => {
if (layoutRef.current) {
if (artifactPanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [artifactPanelOpen]);
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
export { ChatBox };
import type { Message } from "@langchain/langgraph-sdk";
import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
} from "@/core/skill-display/runtime";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { useThread } from "../messages/context";
import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children,
threadId,
}) => {
const { thread } = useThread();
const pathname = usePathname();
const threadIdRef = useRef(threadId);
const layoutRef = useRef<GroupImperativeHandle>(null);
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
deselect,
selectedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const latestAssistant = useMemo(
() => findLatestAssistantMessage(thread.values.messages ?? []),
[thread.values.messages],
);
const displaySources = useMemo(() => {
if (!settings.display.ragModeEnabled) {
return [];
}
const messages = thread.values.messages ?? [];
if (!latestAssistant?.id) {
return [];
}
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
beforeMessageId: latestAssistant.id,
});
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]);
const referenceSources = useMemo(
() => displaySources.filter((source) => source.mode === "citation"),
[displaySources],
);
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
}
}, [hasCitationReferences, latestAssistant?.id]);
useEffect(() => {
const handleShowReference = () => {
setReferencesOpen(true);
setSideTab("references");
};
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
return () => {
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
};
}, []);
useEffect(() => {
if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId;
deselect();
setReferencesOpen(true);
}
// Update artifacts from the current thread
setArtifacts(thread.values.artifacts);
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
// if (
// selectedArtifact &&
// !thread.values.artifacts?.includes(selectedArtifact)
// ) {
// deselect();
// }
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
threadId,
autoSelectFirstArtifact,
deselect,
selectArtifact,
selectedArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
useEffect(() => {
if (!selectedArtifact && hasCitationReferences) {
setSideTab("references");
} else if (selectedArtifact && !hasCitationReferences) {
setSideTab("document");
}
}, [hasCitationReferences, selectedArtifact]);
const resizableIdBase = useMemo(() => {
const raw = pathname
.replace(/[^a-zA-Z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return raw.length > 0 ? raw : `thread-${threadId}`;
}, [pathname, threadId]);
useEffect(() => {
if (layoutRef.current) {
if (sidePanelOpen) {
layoutRef.current.setLayout(OPEN_MODE);
} else {
layoutRef.current.setLayout(CLOSE_MODE);
}
}
}, [sidePanelOpen]);
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation="horizontal"
defaultLayout={{ chat: 100, artifacts: 0 }}
groupRef={layoutRef}
>
<ResizablePanel className="relative" defaultSize={100} id="chat">
{children}
</ResizablePanel>
<ResizableHandle
id={`${resizableIdBase}-separator`}
className={cn(
"opacity-33 hover:opacity-100",
!sidePanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
{showTabs ? (
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<TabsList variant="line">
<TabsTrigger value="document">文档</TabsTrigger>
<TabsTrigger value="references">参考文献</TabsTrigger>
</TabsList>
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setReferencesOpen(false);
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
<ReferencePanel sources={referenceSources} />
</TabsContent>
</Tabs>
) : selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
/>
) : hasCitationReferences ? (
<div className="relative size-full overflow-hidden">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setReferencesOpen(false);
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<ReferencePanel sources={referenceSources} />
</div>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
};
function ReferencePanel({
sources,
}: {
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
}) {
return (
<div className="flex size-full flex-col overflow-hidden">
<header className="shrink-0 px-1 pb-3">
<div className="flex items-baseline gap-2">
<h2 className="text-foreground text-base font-semibold tracking-tight">
参考文献
</h2>
<span className="text-muted-foreground text-xs tabular-nums">
{sources.length} 条
</span>
</div>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
点击编号或卡片可查看引用详情。
</p>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
<SkillDisplaySources sources={sources} />
</main>
</div>
);
}
function findLatestAssistantMessage(messages: Message[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.type === "ai") {
return message;
}
}
return null;
}
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;
}
export { ChatBox };
@@ -1,82 +1,78 @@
import { ExternalLinkIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { Badge } from "@/components/ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
export function CitationLink({
href,
children,
...props
}: ComponentProps<"a">) {
const domain = extractDomain(href ?? "");
// Priority: children > domain
const childrenText =
typeof children === "string"
? children.replace(/^citation:\s*/i, "")
: null;
const isGenericText = childrenText === "Source" || childrenText === "来源";
const displayText = (!isGenericText && childrenText) ?? domain;
return (
<HoverCard closeDelay={0} openDelay={0}>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
onClick={(e) => e.stopPropagation()}
{...props}
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{displayText}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
<div className="p-3">
<div className="space-y-1">
{displayText && (
<h4 className="truncate text-sm leading-tight font-medium">
{displayText}
</h4>
)}
{href && (
<p className="text-muted-foreground truncate text-xs break-all">
{href}
</p>
)}
</div>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</HoverCardContent>
</HoverCard>
);
}
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./i, "");
} catch {
return url;
}
}
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
export function CitationLink({
href,
children,
className,
...props
}: ComponentProps<"a">) {
const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
? href.slice("#reference-".length)
: null;
if (referenceId) {
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
return (
<a
href={href}
title={`查看参考文献 ${badgeLabel}`}
className={cn(
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={(event) => {
event.preventDefault();
window.dispatchEvent(
new CustomEvent(SHOW_REFERENCE_EVENT, {
detail: { referenceId },
}),
);
const target = document.getElementById(`reference-${referenceId}`);
const scroll = () => {
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
if (target) {
scroll();
} else {
window.setTimeout(scroll, 220);
}
}}
{...props}
>
{badgeLabel}
</a>
);
}
return (
<a
href={href}
target={href?.startsWith("http") ? "_blank" : undefined}
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
{...props}
>
{displayText || children}
</a>
);
}
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
if (typeof children !== "string") {
return null;
}
let text = children.trim();
while (/^citation:\s*/i.test(text)) {
text = text.replace(/^citation:\s*/i, "");
}
return text;
}
@@ -2,8 +2,9 @@
import type { ChatStatus } from "ai";
import {
BrainCircuitIcon,
CheckIcon,
BrainCircuitIcon,
BookMarkedIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
LightbulbIcon,
@@ -84,7 +85,8 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useLocalSettings } from "@/core/settings";
import { useThread } from "./messages/context";
@@ -175,7 +177,8 @@ export function InputBox({
onAgentChange: (agentId: string | null) => void;
};
}) {
const { t } = useI18n();
const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels();
@@ -288,13 +291,18 @@ export function InputBox({
});
}, [context, onContextChange]);
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
const handleMemoryInjectionToggle = useCallback(() => {
onContextChange?.({
...context,
memory_injection_enabled: !memoryInjectionEnabled,
});
}, [context, memoryInjectionEnabled, onContextChange]);
}, [context, memoryInjectionEnabled, onContextChange]);
const ragModeEnabled = localSettings.display.ragModeEnabled;
const handleRagModeToggle = useCallback(() => {
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, setLocalSettings]);
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
@@ -620,8 +628,28 @@ export function InputBox({
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
</Tooltip>
<Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
: "参考文献模式已关闭:技能配置的引用展示不会生效"
}
>
<PromptInputButton
className={cn(
"gap-1.5! rounded-full px-3! shadow-none",
ragModeEnabled
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
)}
onClick={handleRagModeToggle}
>
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
<PromptInputActionMenu>
<ModeHoverGuide
mode={
context.mode === "flash" ||
@@ -47,6 +47,15 @@ import {
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
buildSkillDisplayMap,
findDisplayConfigForTool,
normalizeDefaultDisplaySources,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -54,6 +63,7 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content";
@@ -74,6 +84,12 @@ export function MessageGroup({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1];
@@ -142,7 +158,13 @@ export function MessageGroup({
}
></ChainOfThoughtStep>
) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} />
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
),
)}
{lastToolCallStep && (
@@ -152,6 +174,8 @@ export function MessageGroup({
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
</FlipDisplay>
)}
@@ -209,6 +233,8 @@ function ToolCall({
result,
isLast = false,
isLoading = false,
skillDisplayMap = new Map(),
ragModeEnabled = false,
}: {
id?: string;
messageId?: string;
@@ -217,11 +243,48 @@ function ToolCall({
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
ragModeEnabled?: boolean;
}) {
const { t } = useI18n();
const artifacts = useArtifactsOptional();
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
<ChainOfThoughtStep
key={id}
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
icon={SearchIcon}
>
<DisplayResultsList results={displayItems} />
</ChainOfThoughtStep>
);
}
const searchItems = normalizeSearchResults(result);
if (ragModeEnabled && searchItems.length > 0) {
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
...item,
index: index + 1,
skillName: name,
mode: "citation" as const,
}));
return (
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
<DisplayResultsList results={defaultItems} />
</ChainOfThoughtStep>
);
}
if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -534,6 +597,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
);
}
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
<ChevronDownIcon
className={cn(
"size-3.5 transition-transform duration-200",
open && "rotate-180",
)}
/>
<span>{results.length} 条适配结果</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 flex flex-col gap-2">
{results.map((item) => (
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
))}
</CollapsibleContent>
</Collapsible>
);
}
function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content;
@@ -24,26 +24,31 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
parseUploadedFiles,
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { ThreadContext } from "./context";
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 { 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";
/**
@@ -74,14 +79,16 @@ export function MessageListItem({
message,
isLoading,
threadId,
tokenUsageEnabled = false,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
}) {
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const isHuman = message.type === "human";
const messageMarkdown =
extractContentFromMessage(message) ??
@@ -94,11 +101,12 @@ export function MessageListItem({
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
/>
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
/>
{!isLoading && (
<MessageToolbar
className={cn(
@@ -157,14 +165,16 @@ function MessageContent_({
message,
isLoading = false,
threadId,
tokenUsageEnabled = false,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
}) {
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
@@ -173,9 +183,12 @@ function MessageContent_({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
return (
<a
{...props}
@@ -206,12 +219,12 @@ function MessageContent_({
return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const filesList =
files && files.length > 0 ? (
@@ -274,17 +287,17 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<MarkdownContent
content={contentToDisplay}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
className="my-3"
components={components}
/>
<MessageTokenUsage
return (
<AIElementMessageContent className={className}>
{filesList}
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
@@ -459,4 +472,160 @@ function RichFileCard({
);
}
const MessageContent = memo(MessageContent_);
const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() => splitSkillDisplayPlaceholders(content, displaySources),
[content, displaySources],
);
const fallbackBlocks = useMemo(() => {
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys]);
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[],
): { 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) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
placedKeys.add(group.key);
}
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,4 +1,5 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react";
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";
@@ -8,7 +9,7 @@ import {
} from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractPresentFilesFromMessage,
@@ -19,8 +20,14 @@ import {
hasReasoning,
hasToolCalls,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { 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";
import { cn } from "@/lib/utils";
@@ -61,9 +68,15 @@ export function MessageList({
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
// Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message
@@ -179,18 +192,28 @@ export function MessageList({
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
/>
);
});
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
const displaySources =
settings.display.ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
})
: [];
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
/>
);
});
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (message && hasContent(message)) {
@@ -419,15 +442,33 @@ type ClarificationSelection = {
custom: string;
};
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: string;
skills?: string[];
};
function getSetupAgentApprovalPayload(
message: unknown,
export type SetupAgentApprovalPayload = {
name?: string;
description?: string;
soul?: 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(
message: unknown,
): SetupAgentApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -132,11 +132,26 @@ export function AppearanceSettingsPage() {
}
aria-label="回答速度指示器"
/>
</div>
</SettingsSection>
</div>
);
}
</div>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">RAG 引用模式</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
</p>
</div>
<Switch
checked={settings.display.ragModeEnabled}
onCheckedChange={(checked) =>
setSettings("display", { ragModeEnabled: checked })
}
aria-label="RAG 引用模式"
/>
</div>
</SettingsSection>
</div>
);
}
function ThemePreviewCard({
icon: Icon,
@@ -0,0 +1,360 @@
"use client";
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
emptyDisplayConfig,
inferDisplayConfig,
mockKnowledgeSearchV2Result,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import { getSkillDisplaySample } from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
["contentField", "正文内容字段"],
["sourceField", "来源字段"],
["typeField", "类型字段"],
["timeField", "时间字段"],
["scoreField", "相关度字段"],
["urlField", "URL 跳转字段"],
["idField", "ID 跳转字段"],
["linkTemplate", "ID 跳转链接模板"],
];
const displayModeLabels: Record<SkillDisplayMode, string> = {
citation: "编号引用",
list: "列表展示",
table: "表格展示",
card: "卡片展示",
custom: "自定义展示",
none: "不展示",
};
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
auto: "自动判断",
url: "使用 URL 字段跳转",
id: "使用 ID 模板跳转",
};
function updateConfig<K extends keyof SkillDisplayConfig>(
config: SkillDisplayConfig,
key: K,
value: SkillDisplayConfig[K],
): SkillDisplayConfig {
return { ...config, [key]: value };
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const setDisplay = useSetSkillDisplay();
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
// Important: this drives the "保存后回显" behavior. Without depending on
// `skill.display`, any backend-side normalization (e.g. default values
// backfilled by Pydantic, column reordering) would not flow back into the UI.
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
// Sample text is editor-only state — never reset on save. Only seed it once
// per skill, otherwise the user loses the JSON they just pulled in via
// 自动识别 every time they hit 保存.
useEffect(() => {
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
}, [skill.name]);
const samplePayload = useMemo(() => {
try {
return JSON.parse(sampleText);
} catch {
return null;
}
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
function generateDraft() {
if (!samplePayload) {
toast.error("样例返回不是有效的 JSON");
return;
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
}
async function loadSampleAndGenerate() {
try {
const response = await getSkillDisplaySample(skill.name);
const nextSampleText = JSON.stringify(response.sample, null, 2);
setSampleText(nextSampleText);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function saveDraft() {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
toast.success("展示适配已保存");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function clearDisplay() {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
setDraft(emptyDisplayConfig());
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">技能展示适配</div>
<p className="mt-1 text-xs text-muted-foreground">
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用</span>
<Switch
checked={draft.enabled}
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
/>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(displayModeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(linkStrategyLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">最多展示条数</span>
<Input
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
/>
</label>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
value={tableColumnsText}
onChange={(event) => {
setTableColumnsText(event.currentTarget.value);
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
}
} catch {
// Keep typing invalid JSON without crashing the panel.
}
}}
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
className="min-h-24 font-mono text-xs"
/>
</label>
)}
{draft.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>
</label>
)}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium">技能返回样例</div>
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
<WandSparklesIcon className="size-3.5" />
自动识别
</Button>
</div>
<Textarea
value={sampleText}
onChange={(event) => setSampleText(event.currentTarget.value)}
className="min-h-72 font-mono text-xs"
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">展示预览</div>
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
{!samplePayload ? (
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
) : previewItems.length === 0 ? (
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
) : draft.mode === "citation" ? (
<>
<p className="text-sm leading-relaxed">
编号引用预览:
{previewItems.slice(0, 3).map((_item, idx) => (
<button
type="button"
key={idx}
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
>
[{idx + 1}]
</button>
))}
</p>
{previewItems.map((item, idx) => (
<PreviewItem key={idx} index={idx + 1} item={item} />
))}
</>
) : (
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
)}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
清除
</Button>
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
保存展示适配
</Button>
</div>
</div>
);
}
function PreviewItem({
index,
item,
}: {
index: number;
item: ReturnType<typeof normalizeDisplaySources>[number];
}) {
return (
<div className="rounded-md border bg-background p-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">
[{index}] {item.title}
</div>
<div className="mt-1 text-muted-foreground">
{item.type}
{item.source ? ` | ${item.source}` : ""}
{item.time ? ` | ${item.time}` : ""}
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
</div>
</div>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="text-muted-foreground hover:text-foreground"
title="打开链接"
>
<ExternalLinkIcon className="size-4" />
</a>
)}
</div>
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
{item.snippet || item.content || "暂无摘要"}
</p>
</div>
);
}
@@ -0,0 +1,184 @@
import { ExternalLinkIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { SkillDisplayTableColumn } from "@/core/skills/type";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { cn } from "@/lib/utils";
import { SourceCard } from "./skill-display-sources";
export function SkillDisplayBlock({
mode,
sources,
className,
}: {
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
className?: string;
}) {
if (sources.length === 0 || mode === "citation" || mode === "none") {
return null;
}
if (mode === "table") {
return <SkillDisplayTable sources={sources} className={className} />;
}
return (
<div className={cn("not-prose my-4 grid gap-2", className)}>
{sources.map((source) => (
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
))}
</div>
);
}
function SkillDisplayTable({
sources,
className,
}: {
sources: RuntimeDisplaySource[];
className?: string;
}) {
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
const columns = useMemo(() => buildColumns(sources), [sources]);
return (
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
{columns.map((column) => (
<th
key={column.field}
className="px-3 py-2 text-left font-medium"
style={column.width ? { width: column.width } : undefined}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr
key={`${source.skillName}-${source.index}`}
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
onClick={() => setSelected(source)}
>
{columns.map((column) => (
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
<span className="line-clamp-2 break-words">
{formatCell(readByPath(source.raw, column.field))}
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
</div>
);
}
function SourceDetailDialog({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const content = source?.content || source?.snippet;
const meta = source
? [source.source, source.type, source.time].filter(Boolean)
: [];
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
{source && (
<>
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0">
<DialogTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
{meta.length > 0 && (
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
)}
</div>
{source.url && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label="打开来源"
>
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
</div>
</DialogHeader>
{content && (
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
{JSON.stringify(source.raw, null, 2)}
</pre>
</>
)}
</DialogContent>
</Dialog>
);
}
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
if (configured && configured.length > 0) {
return configured;
}
const raw = sources[0]?.raw ?? {};
return Object.keys(raw)
.slice(0, 6)
.map((field) => ({ label: field, field }));
}
function readByPath(value: unknown, path: string): unknown {
if (!path) {
return value;
}
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, value);
}
function formatCell(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
@@ -0,0 +1,155 @@
import { ExternalLinkIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
import { cn } from "@/lib/utils";
import { useState } from "react";
export function SkillDisplaySources({
sources,
className,
}: {
sources: RuntimeDisplaySource[];
className?: string;
}) {
if (sources.length === 0) {
return null;
}
return (
<div className={cn("not-prose grid gap-2", className)}>
{sources.map((source) => (
<SourceCard
key={`${source.skillName}-${source.index}`}
source={source}
referenceTarget
/>
))}
</div>
);
}
export function SourceCard({
source,
referenceTarget = false,
}: {
source: RuntimeDisplaySource;
referenceTarget?: boolean;
}) {
const [open, setOpen] = useState(false);
const content = source.content || source.snippet;
const score =
typeof source.score === "number"
? source.score <= 1
? `${Math.round(source.score * 100)}%`
: String(source.score)
: null;
const meta = [
source.source,
source.type && source.type !== "result" ? source.type : null,
source.time,
score ? `相关度 ${score}` : null,
].filter(Boolean);
const isCitation = source.mode === "citation";
return (
<>
<button
id={referenceTarget ? `reference-${source.index}` : undefined}
type="button"
className={cn(
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
"target:bg-primary/5 target:border-primary/60",
)}
onClick={() => setOpen(true)}
>
<div className="flex items-start gap-3">
{isCitation && (
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
{source.title}
</div>
{meta.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
{meta.map((item, index) => (
<span key={`${item}-${index}`} className="inline-flex items-center">
{index > 0 && <span className="mr-2 opacity-50">·</span>}
<span className="truncate">{item}</span>
</span>
))}
</div>
)}
{content && (
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
{content}
</p>
)}
</div>
{source.url && (
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
)}
</div>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-start justify-between gap-3 pr-8">
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
{isCitation && (
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
{source.index}
</span>
)}
<DialogTitle className="text-base leading-relaxed">
{source.title}
</DialogTitle>
</div>
{meta.length > 0 && (
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
{meta.map((item, index) => (
<span key={`${item}-${index}`}>{item}</span>
))}
</DialogDescription>
)}
</div>
{source.url && (
<Button size="icon-sm" variant="outline" asChild>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
aria-label="打开来源"
>
<ExternalLinkIcon className="size-4" />
</a>
</Button>
)}
</div>
</DialogHeader>
{content && (
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
{content}
</div>
)}
{source.url && (
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
{source.url}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -9,6 +9,7 @@ import {
GlobeIcon,
Loader2,
PencilIcon,
SlidersHorizontalIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -26,6 +27,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
import { getStoredAuth } from "@/core/auth";
@@ -87,7 +89,7 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [activeTab, setActiveTab] = useState<
"detail" | "source" | "files" | null
"detail" | "display" | "source" | "files" | null
>(null);
const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
@@ -206,6 +208,12 @@ export function SkillDetailDialog({
<FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle}
</TabsTrigger>
{canManage && (
<TabsTrigger value="display" className="text-xs">
<SlidersHorizontalIcon className="mr-1 size-3.5" />
展示适配
</TabsTrigger>
)}
{canManage && (
<TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" />
@@ -329,6 +337,16 @@ export function SkillDetailDialog({
)}
</TabsContent>
{canManage && (
<TabsContent
value="display"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
<SkillDisplayAdapterPanel skill={skill} />
</TabsContent>
)}
{canManage && (
<TabsContent
value="files"
+11 -5
View File
@@ -4,9 +4,10 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
display: {
streamSpeedMeterEnabled: true,
},
display: {
streamSpeedMeterEnabled: true,
ragModeEnabled: true,
},
context: {
model_name: undefined,
mode: undefined,
@@ -32,8 +33,13 @@ export interface LocalSettings {
* AI reply is in flight. Default on; controlled by the user from
* the appearance settings page.
*/
streamSpeedMeterEnabled: boolean;
};
streamSpeedMeterEnabled: boolean;
/**
* Global switch for skill result display adapters. When disabled, chat
* messages render tool results with the legacy generic search UI only.
*/
ragModeEnabled: boolean;
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -0,0 +1,209 @@
import type { SkillDisplayConfig } from "@/core/skills/type";
export interface DisplaySourceItem {
id?: string;
title: string;
type: string;
snippet: string;
source?: string;
time?: string;
url?: string;
content?: string;
score?: number;
raw: Record<string, unknown>;
}
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
enabled: false,
mode: "none",
resultPath: "results",
titleField: null,
summaryField: null,
contentField: null,
sourceField: null,
typeField: null,
timeField: null,
scoreField: null,
urlField: null,
idField: null,
linkStrategy: "auto",
linkTemplate: null,
citationToken: "doubleBracket",
maxItems: 8,
customInstruction: "",
tableColumns: [],
});
export const mockKnowledgeSearchV2Result = {
success: true,
query: "latest news",
count: 3,
results: [
{
index: 1,
recUuid: "rec-001",
relevance_score: 0.982,
title: "Knowledge result one",
source_category: "knowledge/news",
source: "internal news library",
publish_time: "2026-05-25",
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
content_length: 1200,
},
{
index: 2,
recUuid: "rec-002",
relevance_score: 0.951,
title: "Knowledge result two",
source_category: "knowledge/news",
source: "internal news library",
publish_time: "2026-05-24",
content_preview: "Mock result used to verify citation ordering and detail preview.",
content_length: 980,
},
{
index: 3,
recUuid: "rec-003",
relevance_score: 0.921,
title: "Knowledge result three",
source_category: "knowledge/commentary",
source: "internal knowledge library",
publish_time: "2026-05-23",
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
content_length: 760,
},
],
};
const candidateFields: Record<string, string[]> = {
titleField: ["title", "m_title", "name", "subject", "headline"],
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
contentField: ["content", "page_content", "text", "content_preview", "body"],
sourceField: ["source", "source_name", "site", "from", "origin"],
typeField: ["type", "category", "source_category", "source1"],
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
urlField: ["url", "link", "href", "source_url"],
idField: ["recUuid", "id", "uuid", "docId", "taskId", "recordId"],
};
function getByPath(value: unknown, path: string): unknown {
if (!path) return value;
return path.split(".").reduce<unknown>((acc, key) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[key];
return undefined;
}, value);
}
function findFirstArray(value: unknown, path = ""): { path: string; items: Record<string, unknown>[] } | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
return { path, items: value as Record<string, unknown>[] };
}
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
const found = findFirstArray(child, path ? `${path}.${key}` : key);
if (found) return found;
}
return null;
}
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
const keys = Object.keys(sample);
for (const candidate of candidates) {
const exact = keys.find((key) => key === candidate);
if (exact) return exact;
}
for (const candidate of candidates) {
const lowered = candidate.toLowerCase();
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
if (fuzzy) return fuzzy;
}
return null;
}
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
const sample = found.items[0] ?? {};
const inferred = emptyDisplayConfig();
inferred.enabled = true;
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
inferred.resultPath = found.path || "results";
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
for (const [target, candidates] of Object.entries(candidateFields)) {
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
}
if (skillName === "knowledge-search-v2") {
inferred.titleField = inferred.titleField || "title";
inferred.summaryField = inferred.summaryField || "content_preview";
inferred.contentField = inferred.contentField || "content_preview";
inferred.sourceField = inferred.sourceField || "source";
inferred.typeField = inferred.typeField || "source_category";
inferred.timeField = inferred.timeField || "publish_time";
inferred.scoreField = inferred.scoreField || "relevance_score";
inferred.idField = inferred.idField || "recUuid";
inferred.linkStrategy = "id";
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
}
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
return inferred;
}
function buildDefaultTableColumns(
sample: Record<string, unknown>,
config: SkillDisplayConfig,
): NonNullable<SkillDisplayConfig["tableColumns"]> {
const preferred = [
config.titleField,
config.typeField,
config.sourceField,
config.timeField,
config.scoreField,
].filter((field): field is string => !!field);
const fallback = Object.keys(sample)
.filter((field) => !preferred.includes(field))
.slice(0, Math.max(0, 6 - preferred.length));
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
label: field,
field,
}));
}
function stringify(value: unknown, fallback = ""): string {
if (value === null || value === undefined) return fallback;
return String(value);
}
function renderTemplate(template: string, item: Record<string, unknown>): string {
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => encodeURIComponent(stringify(item[key])));
}
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
const url = config.urlField ? stringify(item[config.urlField]) : "";
const id = config.idField ? stringify(item[config.idField]) : "";
if (config.linkStrategy === "url") return url || undefined;
if (config.linkStrategy === "id") return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
}
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
const rawItems = getByPath(payload, config.resultPath);
if (!Array.isArray(rawItems)) return [];
return rawItems
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
.slice(0, config.maxItems)
.map((item, index) => ({
id: config.idField ? stringify(item[config.idField]) : undefined,
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`,
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
content: config.contentField ? stringify(item[config.contentField]) : undefined,
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
time: config.timeField ? stringify(item[config.timeField]) : undefined,
score: config.scoreField && typeof item[config.scoreField] === "number" ? item[config.scoreField] as number : undefined,
url: resolveLink(item, config),
raw: item,
}));
}
@@ -0,0 +1,350 @@
import type { Message } from "@langchain/langgraph-sdk";
import { normalizeSearchResults } from "@/core/messages/search-results";
import type { Skill, SkillDisplayConfig } from "@/core/skills/type";
import {
normalizeDisplaySources,
type DisplaySourceItem,
} from "./display-adapter";
export type RuntimeDisplaySource = DisplaySourceItem & {
index: number;
skillName: string;
mode: SkillDisplayConfig["mode"];
tableColumns?: SkillDisplayConfig["tableColumns"];
};
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
const map = new Map<string, SkillDisplayConfig>();
for (const skill of skills) {
const display = skill.display;
if (!display?.enabled || display.mode === "none") {
continue;
}
map.set(skill.name, display);
}
return map;
}
export function findDisplayConfigForTool(
toolName: string,
displays: SkillDisplayMap,
toolArgs?: Record<string, unknown>,
): { skillName: string; display: SkillDisplayConfig } | null {
const exact = displays.get(toolName);
if (exact) {
return { skillName: toolName, display: exact };
}
const normalizedTool = normalizeName(toolName);
for (const [skillName, display] of displays) {
const normalizedSkill = normalizeName(skillName);
if (
normalizedTool === normalizedSkill ||
normalizedTool.endsWith(normalizedSkill) ||
normalizedTool.includes(normalizedSkill)
) {
return { skillName, display };
}
}
// bash/python skill invocations don't carry the skill name in the tool name —
// the path lives inside the command/args (e.g. `python3 skills/custom/demo/scripts/run.py`).
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
// tool_metrics_middleware.py.
if (toolArgs) {
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
if (skillFromArgs) {
const direct = displays.get(skillFromArgs);
if (direct) {
return { skillName: skillFromArgs, display: direct };
}
}
}
return null;
}
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
for (const value of Object.values(args)) {
if (typeof value !== "string") continue;
const match = SKILL_PATH_RE.exec(value);
if (match) {
return match[1];
}
}
return undefined;
}
export function collectRuntimeDisplaySources(
messages: Message[],
displays: SkillDisplayMap,
options: {
afterMessageId?: string;
beforeMessageId?: string;
includeModes?: SkillDisplayConfig["mode"][];
} = {},
): RuntimeDisplaySource[] {
const start =
options.afterMessageId === undefined
? 0
: Math.max(
messages.findIndex((message) => message.id === options.afterMessageId) + 1,
0,
);
const limit =
options.beforeMessageId === undefined
? messages.length
: Math.max(
messages.findIndex((message) => message.id === options.beforeMessageId),
0,
);
const allowedModes = options.includeModes
? new Set(options.includeModes)
: null;
const sources: RuntimeDisplaySource[] = [];
let citationIndex = 0;
const displayIndexes = new Map<string, number>();
for (let index = start; index < limit; index++) {
const message = messages[index];
if (!message || message.type !== "ai") {
continue;
}
for (const toolCall of message.tool_calls ?? []) {
const match = findDisplayConfigForTool(
toolCall.name,
displays,
toolCall.args as Record<string, unknown> | undefined,
);
const effectiveMode = match?.display.mode ?? "citation";
if (allowedModes && !allowedModes.has(effectiveMode)) {
continue;
}
const result = findToolResultPayload(toolCall.id, messages);
if (result === undefined) {
continue;
}
const normalized = match
? normalizeDisplaySources(result, match.display)
: normalizeDefaultDisplaySources(result);
for (const item of normalized) {
const skillName = match?.skillName ?? toolCall.name;
const displayKey = `${skillName}:${effectiveMode}`;
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
displayIndexes.set(displayKey, displayIndex);
sources.push({
...item,
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
skillName,
mode: effectiveMode,
tableColumns: match?.display.tableColumns,
});
}
}
}
return sources;
}
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
const normalized = normalizeSearchResults(
typeof payload === "string" || isRecord(payload)
? (payload as string | Record<string, unknown>)
: undefined,
);
return normalized.map((item, index) => ({
id: item.url ?? String(index + 1),
title: item.title ?? item.source ?? `结果 ${index + 1}`,
type: "result",
snippet: item.snippet ?? item.content ?? "",
content: item.content ?? item.snippet,
source: item.source,
time: item.published_date ?? item.publishedDate ?? item.date,
url: item.url,
raw: item as unknown as Record<string, unknown>,
}));
}
export function applyInlineCitations(
content: string,
sources: RuntimeDisplaySource[],
): string {
if (!content || sources.length === 0) {
return content;
}
const citationSources = sources.filter((source) => source.mode === "citation");
if (citationSources.length === 0) {
return content;
}
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
const byUrl = new Map<string, RuntimeDisplaySource>();
const byTitle = new Map<string, RuntimeDisplaySource>();
for (const source of citationSources) {
if (source.url) {
byUrl.set(source.url, source);
}
if (source.title) {
byTitle.set(source.title.trim(), source);
}
}
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
let cursor = 0;
let next = "";
for (const match of normalized.matchAll(markdownLinkPattern)) {
const start = match.index ?? 0;
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
next += match[0];
cursor = start + match[0].length;
}
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
return next;
}
function replaceCitationTokens(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
): string {
return content
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
citationMarkdown(match, Number(rawIndex), byIndex),
)
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
);
}
function normalizeExistingCitationLinks(
content: string,
byIndex: Map<number, RuntimeDisplaySource>,
byUrl: Map<string, RuntimeDisplaySource>,
byTitle: Map<string, RuntimeDisplaySource>,
): string {
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
// ("[1]", "1") or a free-form title — to a numbered reference link that
// CitationLink renders as a badge and scrolls to the right-side panel.
return content.replace(
/\[citation:([^\]]+)\]\(([^)]+)\)(?:\([^)]+\))*/g,
(match, rawLabel: string, rawUrl: string) => {
const label = rawLabel.trim();
const url = rawUrl.trim();
const numberMatch = label.match(/^\[?(\d+)\]?$/);
if (numberMatch) {
const index = Number(numberMatch[1]);
if (byIndex.has(index)) {
return `[citation:[${index}]](#reference-${index})`;
}
}
const sourceByUrl = byUrl.get(url);
if (sourceByUrl) {
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
}
const sourceByTitle = byTitle.get(label);
if (sourceByTitle) {
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
}
return match;
},
);
}
function citationMarkdown(
fallback: string,
index: number,
byIndex: Map<number, RuntimeDisplaySource>,
): string {
const source = byIndex.get(index);
if (!source) {
return fallback;
}
return `[citation:[${index}]](#reference-${index})`;
}
function normalizeName(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function findToolResultPayload(
toolCallId: string | undefined,
messages: Message[],
): unknown {
if (!toolCallId) {
return undefined;
}
const toolMessage = messages.find(
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
);
if (!toolMessage) {
return undefined;
}
const content = extractToolText(toolMessage.content);
if (!content) {
return undefined;
}
try {
return JSON.parse(content) as unknown;
} catch {
// bash stdout on the local sandbox can be suffixed with
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
// and skills sometimes print informational lines around their JSON payload.
// Salvage the first balanced JSON object or array we can find before
// falling back to the raw string.
const recovered = extractJSONFromText(content);
if (recovered !== undefined) {
return recovered;
}
return content;
}
}
function extractJSONFromText(text: string): unknown {
const candidates: Array<[number, number]> = [];
const firstObj = text.indexOf("{");
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
const firstArr = text.indexOf("[");
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
candidates.sort(([a], [b]) => a - b);
for (const [start, end] of candidates) {
if (start < 0 || end <= start) continue;
const slice = text.slice(start, end + 1);
try {
return JSON.parse(slice);
} catch {
// try the next candidate
}
}
return undefined;
}
function extractToolText(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object" && "text" in part) {
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
}
return "";
})
.join("\n")
.trim();
}
return "";
}
+41 -3
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type";
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams {
search?: string;
@@ -30,7 +30,7 @@ export async function getSkill(skillName: string): Promise<Skill> {
return response.json() as Promise<Skill>;
}
export async function enableSkill(skillName: string, enabled: boolean) {
export async function enableSkill(skillName: string, enabled: boolean) {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${skillName}`,
{
@@ -44,7 +44,45 @@ export async function enableSkill(skillName: string, enabled: boolean) {
},
);
return response.json();
}
}
export async function setSkillDisplay(
skillName: string,
display: SkillDisplayConfig | null,
): Promise<Skill> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ display }),
},
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to update skill display adapter");
}
return response.json() as Promise<Skill>;
}
export interface SkillDisplaySampleResponse {
skill_name: string;
sample: Record<string, unknown>;
}
export async function getSkillDisplaySample(
skillName: string,
): Promise<SkillDisplaySampleResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
{ method: "POST" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to get skill display sample");
}
return response.json() as Promise<SkillDisplaySampleResponse>;
}
export interface InstallSkillRequest {
thread_id: string;
+24 -6
View File
@@ -14,14 +14,15 @@ import {
getSkillFiles,
installSkillUpload,
reconcileCustomSkills,
saveSkillFile,
setSkillDetail,
setSkillPinned,
saveSkillFile,
setSkillDetail,
setSkillDisplay,
setSkillPinned,
setSkillPublished,
setSkillState,
validateSkillUpload,
} from "./api";
import type { SkillState } from "./type";
import type { SkillState } from "./type";
import { loadSkills } from ".";
@@ -52,7 +53,7 @@ export function useSkill(skillName: string | null) {
});
}
export function useSetSkillDetail() {
export function useSetSkillDetail() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
@@ -62,7 +63,24 @@ export function useSetSkillDetail() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
}
export function useSetSkillDisplay() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
display,
}: {
skillName: string;
display: Parameters<typeof setSkillDisplay>[1];
}) => setSkillDisplay(skillName, display),
onSuccess: (_data, { skillName }) => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
},
});
}
export function useEnableSkill() {
const queryClient = useQueryClient();
+31
View File
@@ -2,6 +2,36 @@ import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload";
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
export type SkillLinkStrategy = "auto" | "url" | "id";
export type SkillCitationToken = "doubleBracket" | "singleBracket";
export interface SkillDisplayTableColumn {
label: string;
field: string;
width?: string | null;
}
export interface SkillDisplayConfig {
enabled: boolean;
mode: SkillDisplayMode;
resultPath: string;
titleField?: string | null;
summaryField?: string | null;
contentField?: string | null;
sourceField?: string | null;
typeField?: string | null;
timeField?: string | null;
scoreField?: string | null;
urlField?: string | null;
idField?: string | null;
linkStrategy: SkillLinkStrategy;
linkTemplate?: string | null;
citationToken: SkillCitationToken;
maxItems: number;
customInstruction: string;
tableColumns?: SkillDisplayTableColumn[];
}
export interface Skill {
name: string;
@@ -17,6 +47,7 @@ export interface Skill {
// Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null;
display?: SkillDisplayConfig | null;
// Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead.
state?: SkillState;
+9 -6
View File
@@ -12,7 +12,8 @@ import { apiFetch } from "../api";
import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import type { LocalSettings } from "../settings";
import { useLocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -145,7 +146,8 @@ export function useThreadStream({
onFinish,
onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n();
const { t } = useI18n();
const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders,
@@ -506,8 +508,9 @@ export function useThreadStream({
: context.mode === "thinking"
? "low"
: undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true,
thread_id: threadId,
memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: localSettings.display.ragModeEnabled,
thread_id: threadId,
},
},
);
@@ -520,8 +523,8 @@ export function useThreadStream({
sendInFlightRef.current = false;
}
},
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
);
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
);
// Merge thread with optimistic messages for display
const mergedThread =
+3 -2
View File
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean;
writing_artifact_path?: string;
memory_injection_enabled?: boolean;
agent_id?: string;
memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
}
@@ -182,10 +182,13 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request
current_config = get_extensions_config()
# Convert request to dict format for JSON serialization
config_data = {
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
}
config_data = {
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
# Write the configuration to file
with open(config_path, "w", encoding="utf-8") as f:
@@ -1,6 +1,8 @@
import asyncio
import json
import logging
import mimetypes
import re
import tempfile
from datetime import datetime
from pathlib import Path
@@ -14,7 +16,7 @@ from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_t
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig
from deerflow.config.extensions_config import ExtensionsConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.config.extensions_config import ExtensionsConfig, SkillDisplayConfig, SkillStateConfig, get_extensions_config, reload_extensions_config
from deerflow.persistence.skills import SkillStore
from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id
@@ -117,6 +119,7 @@ class SkillResponse(BaseModel):
published: bool = Field(default=False, description="Whether the owner has made this skill visible to other users")
builtin: bool = Field(default=False, description="True when owner_user_id is null (no human owner)")
detail: str | None = Field(default=None, description="Human-authored long-form detail text (Markdown), editable by owner/admin")
display: dict[str, Any] | None = Field(default=None, description="Optional frontend display adapter")
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this skill")
@@ -132,6 +135,19 @@ class SkillUpdateRequest(BaseModel):
enabled: bool = Field(..., description="Whether to enable or disable the skill")
class SkillDisplayUpdateRequest(BaseModel):
"""Request model for updating a skill's frontend display adapter."""
display: SkillDisplayConfig | None = Field(default=None, description="Display adapter configuration; null clears it")
class SkillDisplaySampleResponse(BaseModel):
"""Sample structured output used to infer a display adapter."""
skill_name: str
sample: dict[str, Any]
class SkillInstallRequest(BaseModel):
"""Request model for installing a skill from a .skill file."""
@@ -215,6 +231,14 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail")
display = None
try:
skill_config = get_extensions_config().skills.get(skill.name)
if skill_config and skill_config.display:
display = skill_config.display.model_dump(by_alias=True)
except Exception:
logger.debug("Failed to read display adapter for %s", skill.name, exc_info=True)
return SkillResponse(
name=skill.name,
description=skill.description,
@@ -232,9 +256,164 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
published=published,
builtin=builtin,
detail=detail,
display=display,
)
def _dump_extensions_config(config_obj: ExtensionsConfig) -> dict[str, Any]:
return {
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in config_obj.mcp_servers.items()},
"skills": {name: skill_config.model_dump(by_alias=True, exclude_none=True) for name, skill_config in config_obj.skills.items()},
}
_SKILL_SAMPLE_SYSTEM_PROMPT = (
"You infer realistic JSON return shapes for AI agent skills. "
"Given a skill's name, description, and full SKILL.md, output a SINGLE JSON object that "
"represents a plausible successful response from this skill's primary action. "
"Rules:\n"
"- Output JSON ONLY. No prose. No markdown fences. No comments.\n"
"- The top level must be a JSON object (not an array).\n"
"- Include a results/items/list/records array (pick the most natural key) with 2-3 example entries.\n"
"- Use realistic, idiomatic field names (e.g. title, content, source, publish_time, score, url, id) that match the skill's domain.\n"
"- Each entry should have enough fields to drive a citation/list/table UI: a title, a short snippet/content, a source, a time/date, and an id or url when relevant.\n"
"- Numbers stay numbers, dates stay ISO strings, scores between 0 and 1 when applicable.\n"
"- Do not invent fields that contradict the skill description."
)
def _extract_json_from_llm_output(raw: str) -> dict[str, Any] | None:
"""Best-effort extraction of a JSON object from an LLM response."""
text = (raw or "").strip()
if not text:
return None
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL | re.IGNORECASE)
if fence:
text = fence.group(1).strip()
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
pass
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
parsed = json.loads(match.group(0))
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
async def _generate_skill_display_sample(skill: Skill, app_config: AppConfig) -> dict[str, Any]:
"""Generate a realistic return-shape sample for ``skill`` via the LLM.
Reads SKILL.md, asks the configured curator model (or main model) to infer
a plausible JSON response. Falls back to :func:`_mock_skill_display_sample`
when the model is unconfigured, the call times out, or the output cannot
be parsed as a JSON object.
"""
try:
skill_content = skill.skill_file.read_text(encoding="utf-8")
except OSError as exc:
logger.debug("Cannot read SKILL.md for %s (%s); falling back to description only", skill.name, exc)
skill_content = ""
# Cap the SKILL.md body so very large workflow docs don't blow the context.
max_chars = 6000
if len(skill_content) > max_chars:
skill_content = skill_content[:max_chars] + "\n…(truncated)"
user_prompt = (
f"Skill name: {skill.name}\n"
f"Short description: {skill.description}\n\n"
f"SKILL.md:\n-----\n{skill_content or '(SKILL.md unavailable; rely on the short description above.)'}\n-----\n\n"
"Return one JSON object that matches what this skill would actually produce on success."
)
try:
from deerflow.models import create_chat_model
# Reuse the curator/security-scan model setting; falls back to the main model when unset.
model_name = app_config.skill_evolution.curator.model_name if app_config.skill_evolution and app_config.skill_evolution.curator else None
model = create_chat_model(name=model_name, thinking_enabled=False, app_config=app_config)
response = await asyncio.wait_for(
model.ainvoke(
[
{"role": "system", "content": _SKILL_SAMPLE_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
config={"run_name": "skill_display_sample"},
),
timeout=45,
)
parsed = _extract_json_from_llm_output(str(getattr(response, "content", "") or ""))
if parsed:
return parsed
logger.warning("Skill display sample model returned non-JSON for %s; falling back to static mock", skill.name)
except asyncio.TimeoutError:
logger.warning("Skill display sample LLM call timed out for %s; falling back to static mock", skill.name)
except Exception:
logger.warning("Skill display sample LLM call failed for %s; falling back to static mock", skill.name, exc_info=True)
return _mock_skill_display_sample(skill.name)
def _mock_skill_display_sample(skill_name: str) -> dict[str, Any]:
if skill_name == "knowledge-search-v2":
return {
"success": True,
"query": "latest news",
"count": 3,
"results": [
{
"index": 1,
"recUuid": "rec-001",
"relevance_score": 0.982,
"title": "Knowledge result one",
"source_category": "knowledge/news",
"source": "internal news library",
"publish_time": "2026-05-25",
"content_preview": "Mock result used to verify citation numbers, source cards, and recUuid template links.",
"content_length": 1200,
},
{
"index": 2,
"recUuid": "rec-002",
"relevance_score": 0.951,
"title": "Knowledge result two",
"source_category": "knowledge/news",
"source": "internal news library",
"publish_time": "2026-05-24",
"content_preview": "Mock result used to verify citation ordering and detail preview.",
"content_length": 980,
},
{
"index": 3,
"recUuid": "rec-003",
"relevance_score": 0.921,
"title": "Knowledge result three",
"source_category": "knowledge/commentary",
"source": "internal knowledge library",
"publish_time": "2026-05-23",
"content_preview": "Mock result used to verify list mode and custom display instruction previews.",
"content_length": 760,
},
],
}
return {
"success": True,
"query": "sample query",
"results": [
{
"id": "sample-001",
"title": "Sample result",
"summary": "Sample structured output used to infer field mapping.",
"source": "sample",
"updatedAt": "2026-05-25",
}
],
}
async def _build_ownership_index(skill_store: SkillStore, user_id: str) -> dict[str, dict[str, Any]]:
"""Index visible-to-user skill records by name. Internal call cache for list endpoints."""
records = await skill_store.list_visible(user_id)
@@ -1423,12 +1602,13 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
extensions_config = get_extensions_config()
extensions_config.skills[skill_name] = SkillStateConfig(enabled=request.enabled)
current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig(
enabled=request.enabled,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {name: server.model_dump() for name, server in extensions_config.mcp_servers.items()},
"skills": {name: {"enabled": skill_config.enabled} for name, skill_config in extensions_config.skills.items()},
}
config_data = _dump_extensions_config(extensions_config)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2)
@@ -1453,6 +1633,79 @@ async def update_skill(skill_name: str, request: SkillUpdateRequest, config: App
raise HTTPException(status_code=500, detail=f"Failed to update skill: {str(e)}")
@router.put(
"/skills/{skill_name}/display",
response_model=SkillResponse,
summary="Update Skill Display Adapter",
description="Update a skill's frontend display adapter in extensions_config.json.",
)
async def update_skill_display(
skill_name: str,
request: SkillDisplayUpdateRequest,
config: AppConfig = Depends(get_config),
) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
config_path = ExtensionsConfig.resolve_config_path()
if config_path is None:
config_path = Path.cwd().parent / "extensions_config.json"
logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(skill_name)
extensions_config.skills[skill_name] = SkillStateConfig(
enabled=current_skill_config.enabled if current_skill_config else skill.enabled,
display=request.display,
)
with open(config_path, "w", encoding="utf-8") as f:
json.dump(_dump_extensions_config(extensions_config), f, indent=2, ensure_ascii=False)
reload_extensions_config()
await refresh_skills_system_prompt_cache_async()
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
updated_skill = next((s for s in skills if s.name == skill_name), None)
if updated_skill is None:
raise HTTPException(status_code=500, detail=f"Failed to reload skill '{skill_name}' after display update")
return _skill_to_response(updated_skill)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to update display adapter for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update skill display adapter: {str(e)}")
@router.post(
"/skills/{skill_name}/display-sample",
response_model=SkillDisplaySampleResponse,
summary="Get Skill Display Sample",
description="Return a structured sample response used by the frontend to infer display mapping.",
)
async def get_skill_display_sample(
skill_name: str,
config: AppConfig = Depends(get_config),
) -> SkillDisplaySampleResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
sample = await _generate_skill_display_sample(skill, config)
return SkillDisplaySampleResponse(skill_name=skill_name, sample=sample)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get display sample for %s: %s", skill_name, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to get display sample: {str(e)}")
class SkillDetailRequest(BaseModel):
"""Request body for setting a skill's human-authored detail text."""
@@ -152,8 +152,9 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"model_name",
"mode",
"thinking_enabled",
"reasoning_effort",
"writing_mode",
"reasoning_effort",
"rag_mode_enabled",
"writing_mode",
"writing_artifact_path",
"canvas_artifact_title",
"canvas_artifact_type",
@@ -281,9 +281,9 @@ def _build_middlewares(
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
@@ -338,9 +338,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
is_plan_mode = cfg.get("is_plan_mode", False)
subagent_enabled = cfg.get("subagent_enabled", False)
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))
writing_mode = bool(cfg.get("writing_mode", False))
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():
writing_artifact_path = None
@@ -427,8 +428,9 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
available_skills=None,
is_bootstrap=True,
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,
)
@@ -452,7 +454,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
writing_artifact_path=writing_artifact_path,
available_skills=(set(agent_config.skills or []) if agent_config else None),
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,
)
@@ -665,20 +665,28 @@ 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], ...],
available_skills_key: tuple[str, ...] | None,
container_base_path: str,
skill_evolution_section: str,
) -> str:
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" <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
)
skills_list = f"<available_skills>\n{skill_items}\n</available_skills>"
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:
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 <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>"
# Restricted mode: agent has an explicit skill allowlist.
# We deliberately omit the "Skills are located at: <path>" hint and the
@@ -690,25 +698,44 @@ def _get_cached_skills_prompt_section(
if is_restricted:
scope_note = "\nYou have access ONLY to the skills listed below. Do NOT attempt to read, list, or invoke any skill that is not in this list, even if you remember other skill paths.\n"
else:
scope_note = ""
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.
{scope_note}
**Progressive Loading Pattern:**
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
else:
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>
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}
**Progressive Loading Pattern:**
1. When a user query matches a skill's use case, immediately call `read_file` on the skill's main file using the path attribute provided in the skill tag below
2. Read and understand the skill's workflow and instructions
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
{location_hint}{evolution_block}
{skills_list}
</skill_system>"""
def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_config: AppConfig | None = None) -> str:
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}
</skill_system>"""
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)
@@ -738,12 +765,31 @@ def get_skills_prompt_section(available_skills: set[str] | None = None, *, app_c
if available_skills is not None and not any(skill.name in available_skills for skill in skills):
return ""
skill_signature = tuple((skill.name, skill.description, skill.category, skill.get_container_file_path(container_base_path)) for skill in skills)
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)
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, display_signature, available_key, container_base_path, skill_evolution_section)
def get_agent_soul(agent_id: str | None) -> str:
@@ -866,9 +912,10 @@ def apply_prompt_template(
writing_artifact_path: str | None = None,
available_skills: set[str] | None = None,
is_bootstrap: bool = False,
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
) -> str:
app_config: AppConfig | None = None,
memory_injection_enabled: bool = True,
rag_mode_enabled: bool = False,
) -> str:
storage_agent_id = agent_id if agent_id is not None else agent_name
# Get memory context
@@ -896,11 +943,15 @@ def apply_prompt_template(
else ""
)
# Bootstrap section: skill-matching guidance (only in agent creation flow)
# Bootstrap section: skill-matching guidance (only in agent creation flow)
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)
@@ -937,7 +988,22 @@ Writing mode is enabled for this run.
acp_and_mounts_section = "\n".join(section for section in (acp_section, custom_mounts_section) if section)
# 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
prompt = SYSTEM_PROMPT_TEMPLATE.format(
@@ -955,4 +1021,4 @@ Writing mode is enabled for this run.
acp_section=acp_and_mounts_section,
)
return prompt + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
return prompt + rag_mode_section + f"\n<current_date>{datetime.now().strftime('%Y-%m-%d, %A')}</current_date>"
@@ -840,10 +840,13 @@ class DeerFlowClient:
current_config = get_extensions_config()
config_data = {
"mcpServers": mcp_servers,
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
}
config_data = {
"mcpServers": mcp_servers,
"skills": {
name: skill.model_dump(by_alias=True, exclude_none=True)
for name, skill in current_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -903,13 +906,20 @@ class DeerFlowClient:
if config_path is None:
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.")
extensions_config = get_extensions_config()
extensions_config.skills[name] = SkillStateConfig(enabled=enabled)
config_data = {
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()},
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.items()},
}
extensions_config = get_extensions_config()
current_skill_config = extensions_config.skills.get(name)
extensions_config.skills[name] = SkillStateConfig(
enabled=enabled,
display=current_skill_config.display if current_skill_config else None,
)
config_data = {
"mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {
n: sc.model_dump(by_alias=True, exclude_none=True)
for n, sc in extensions_config.skills.items()
},
}
self._atomic_write_json(config_path, config_data)
@@ -3,7 +3,7 @@
import json
import os
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -53,10 +53,63 @@ class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
class SkillDisplayTableColumn(BaseModel):
"""A configured table column for structured skill result display."""
label: str = Field(description="Column label shown in the frontend table")
field: str = Field(description="Field name or dot path read from each result item")
width: str | None = Field(default=None, description="Optional CSS width for this column")
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillDisplayConfig(BaseModel):
"""Frontend display adapter for structured skill results."""
enabled: bool = Field(default=False, description="Whether result display adaptation is enabled")
mode: Literal["citation", "list", "table", "card", "custom", "none"] = Field(
default="none",
description="How the frontend should render this skill's structured results",
)
result_path: str = Field(default="results", description="Dot path to the array of result items", alias="resultPath")
title_field: str | None = Field(default=None, description="Field used as item title", alias="titleField")
summary_field: str | None = Field(default=None, description="Field used as short summary/snippet", alias="summaryField")
content_field: str | None = Field(default=None, description="Field used as full item content", alias="contentField")
source_field: str | None = Field(default=None, description="Field used as source label", alias="sourceField")
type_field: str | None = Field(default=None, description="Field used as source/type badge", alias="typeField")
time_field: str | None = Field(default=None, description="Field used as publication/update time", alias="timeField")
score_field: str | None = Field(default=None, description="Field used as relevance score", alias="scoreField")
url_field: str | None = Field(default=None, description="Field containing a direct URL", alias="urlField")
id_field: str | None = Field(default=None, description="Field containing an ID for template URLs", alias="idField")
link_strategy: Literal["auto", "url", "id"] = Field(
default="auto",
description="Whether links use a URL field, an ID template, or automatic fallback",
alias="linkStrategy",
)
link_template: str | None = Field(default=None, description="Template used when linkStrategy is id", alias="linkTemplate")
citation_token: Literal["doubleBracket", "singleBracket"] = Field(
default="doubleBracket",
description="Citation token style expected in model output",
alias="citationToken",
)
max_items: int = Field(default=8, ge=1, le=100, description="Maximum displayed items", alias="maxItems")
custom_instruction: str = Field(
default="",
description="User-authored instruction for custom rendering schema generation",
alias="customInstruction",
)
table_columns: list[SkillDisplayTableColumn] = Field(
default_factory=list,
description="Columns used when mode is table",
alias="tableColumns",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled")
display: SkillDisplayConfig | None = Field(default=None, description="Optional frontend display adapter")
class ExtensionsConfig(BaseModel):