新增 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:
吉鑫升 authored and xuhd committed 2026-05-28 18:28:33 +08:00
1 parent 96b0bae6ca
commit 97f24f17eb
22 files changed
+2070 -87

No files matched your search

@@ -1,3 +1,4 @@
import type { Message } from "@langchain/langgraph-sdk";
import { FilesIcon, XIcon } from "lucide-react"; import { FilesIcon, XIcon } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -10,6 +11,13 @@ import {
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
} from "@/components/ui/resizable"; } 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 { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -19,9 +27,11 @@ import {
useArtifacts, useArtifacts,
} from "../artifacts"; } from "../artifacts";
import { useThread } from "../messages/context"; import { useThread } from "../messages/context";
import { SkillDisplaySources } from "../skill-display-sources";
const CLOSE_MODE = { chat: 100, artifacts: 0 }; const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 }; const OPEN_MODE = { chat: 60, artifacts: 40 };
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children, children,
@@ -31,6 +41,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const pathname = usePathname(); const pathname = usePathname();
const threadIdRef = useRef(threadId); const threadIdRef = useRef(threadId);
const layoutRef = useRef<GroupImperativeHandle>(null); const layoutRef = useRef<GroupImperativeHandle>(null);
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const { const {
artifacts, artifacts,
@@ -43,10 +57,52 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} = useArtifacts(); } = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); 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(() => { useEffect(() => {
if (threadIdRef.current !== threadId) { if (threadIdRef.current !== threadId) {
threadIdRef.current = threadId; threadIdRef.current = threadId;
deselect(); deselect();
setReferencesOpen(true);
} }
// Update artifacts from the current thread // Update artifacts from the current thread
@@ -85,6 +141,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
} }
return artifactsOpen; return artifactsOpen;
}, [artifactsOpen, artifacts]); }, [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 resizableIdBase = useMemo(() => {
const raw = pathname const raw = pathname
@@ -95,13 +161,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
useEffect(() => { useEffect(() => {
if (layoutRef.current) { if (layoutRef.current) {
if (artifactPanelOpen) { if (sidePanelOpen) {
layoutRef.current.setLayout(OPEN_MODE); layoutRef.current.setLayout(OPEN_MODE);
} else { } else {
layoutRef.current.setLayout(CLOSE_MODE); layoutRef.current.setLayout(CLOSE_MODE);
} }
} }
}, [artifactPanelOpen]); }, [sidePanelOpen]);
return ( return (
<ResizablePanelGroup <ResizablePanelGroup
@@ -117,28 +183,73 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
id={`${resizableIdBase}-separator`} id={`${resizableIdBase}-separator`}
className={cn( className={cn(
"opacity-33 hover:opacity-100", "opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0", !sidePanelOpen && "pointer-events-none opacity-0",
)} )}
/> />
<ResizablePanel <ResizablePanel
className={cn( className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out", "bg-[#fffdfa] transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0", !sidePanelOpen && "opacity-0",
)} )}
id="artifacts" id="artifacts"
> >
<div <div
className={cn( className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out", "h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full", sidePanelOpen ? "translate-x-0" : "translate-x-full",
)} )}
> >
{selectedArtifact ? ( {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 <ArtifactFileDetail
className="size-full" className="size-full"
filepath={selectedArtifact} filepath={selectedArtifact}
threadId={threadId} 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="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30"> <div className="absolute top-1 right-1 z-30">
@@ -180,4 +291,59 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
); );
}; };
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 }; export { ChatBox };
@@ -1,82 +1,78 @@
import { ExternalLinkIcon } from "lucide-react";
import type { ComponentProps } from "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"; import { cn } from "@/lib/utils";
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
export function CitationLink({ export function CitationLink({
href, href,
children, children,
className,
...props ...props
}: ComponentProps<"a">) { }: ComponentProps<"a">) {
const domain = extractDomain(href ?? ""); const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
? href.slice("#reference-".length)
: null;
// Priority: children > domain if (referenceId) {
const childrenText = const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
typeof children === "string" return (
? children.replace(/^citation:\s*/i, "") <a
: null; href={href}
const isGenericText = childrenText === "Source" || childrenText === "来源"; title={`查看参考文献 ${badgeLabel}`}
const displayText = (!isGenericText && childrenText) ?? domain; 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 ( return (
<HoverCard closeDelay={0} openDelay={0}> <a
<HoverCardTrigger asChild> href={href}
<a target={href?.startsWith("http") ? "_blank" : undefined}
href={href} rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
target="_blank" className={cn(
rel="noopener noreferrer" "text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className="inline-flex items-center" className,
onClick={(e) => e.stopPropagation()} )}
{...props} {...props}
> >
<Badge {displayText || children}
variant="secondary" </a>
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 { function normalizeCitationText(children: ComponentProps<"a">["children"]) {
try { if (typeof children !== "string") {
return new URL(url).hostname.replace(/^www\./i, ""); return null;
} catch {
return url;
} }
let text = children.trim();
while (/^citation:\s*/i.test(text)) {
text = text.replace(/^citation:\s*/i, "");
}
return text;
} }
@@ -3,6 +3,7 @@
import type { ChatStatus } from "ai"; import type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon, BrainCircuitIcon,
BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
@@ -86,6 +87,7 @@ import {
} from "../ui/dropdown-menu"; } 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"; import { useThread } from "./messages/context";
@@ -177,6 +179,7 @@ export function InputBox({
}; };
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false); const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels(); const { models } = useModels();
@@ -297,6 +300,11 @@ export function InputBox({
}); });
}, [context, memoryInjectionEnabled, onContextChange]); }, [context, memoryInjectionEnabled, onContextChange]);
const ragModeEnabled = localSettings.display.ragModeEnabled;
const handleRagModeToggle = useCallback(() => {
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
}, [ragModeEnabled, setLocalSettings]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -622,6 +630,26 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </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> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -47,6 +47,15 @@ import {
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import {
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 { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -54,6 +63,7 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
@@ -74,6 +84,12 @@ export function MessageGroup({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const steps = useMemo(() => convertToSteps(messages), [messages]); const steps = useMemo(() => convertToSteps(messages), [messages]);
const [settings] = useLocalSettings();
const { skills } = useSkills();
const skillDisplayMap = useMemo(
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
[settings.display.ragModeEnabled, skills],
);
const lastToolCallStep = useMemo(() => { const lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
@@ -142,7 +158,13 @@ export function MessageGroup({
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
) : ( ) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} /> <ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
@@ -152,6 +174,8 @@ export function MessageGroup({
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
@@ -209,6 +233,8 @@ function ToolCall({
result, result,
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(),
ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
messageId?: string; messageId?: string;
@@ -217,11 +243,48 @@ function ToolCall({
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const artifacts = useArtifactsOptional(); 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); 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) { if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result); const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -544,6 +607,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 }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content; const content = item.snippet ?? item.content;
@@ -125,6 +125,21 @@ export function AppearanceSettingsPage() {
aria-label="回答速度指示器" aria-label="回答速度指示器"
/> />
</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> </SettingsSection>
</div> </div>
); );
@@ -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, GlobeIcon,
Loader2, Loader2,
PencilIcon, PencilIcon,
SlidersHorizontalIcon,
SparklesIcon, SparklesIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -26,6 +27,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; 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 { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree"; import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
@@ -88,7 +90,7 @@ export function SkillDetailDialog({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"detail" | "source" | "files" | null "detail" | "display" | "source" | "files" | null
>(null); >(null);
const [showRawSource, setShowRawSource] = useState(false); const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null); const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
@@ -207,6 +209,12 @@ export function SkillDetailDialog({
<FileTextIcon className="mr-1 size-3.5" /> <FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle} {tx.detailSectionTitle}
</TabsTrigger> </TabsTrigger>
{canManage && (
<TabsTrigger value="display" className="text-xs">
<SlidersHorizontalIcon className="mr-1 size-3.5" />
展示适配
</TabsTrigger>
)}
{canManage && ( {canManage && (
<TabsTrigger value="files" className="text-xs"> <TabsTrigger value="files" className="text-xs">
<FilesIcon className="mr-1 size-3.5" /> <FilesIcon className="mr-1 size-3.5" />
@@ -330,6 +338,16 @@ export function SkillDetailDialog({
)} )}
</TabsContent> </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 && ( {canManage && (
<TabsContent <TabsContent
value="files" value="files"
+6
View File
@@ -6,6 +6,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
}, },
display: { display: {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
ragModeEnabled: true,
}, },
context: { context: {
model_name: undefined, model_name: undefined,
@@ -33,6 +34,11 @@ export interface LocalSettings {
* the appearance settings page. * 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< context: Omit<
AgentThreadContext, AgentThreadContext,
@@ -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 "";
}
+39 -1
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type"; import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams { export interface ListSkillsParams {
search?: string; search?: string;
@@ -46,6 +46,44 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json(); 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 { export interface InstallSkillRequest {
thread_id: string; thread_id: string;
path: string; path: string;
+18
View File
@@ -16,6 +16,7 @@ import {
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile, saveSkillFile,
setSkillDetail, setSkillDetail,
setSkillDisplay,
setSkillPinned, setSkillPinned,
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
@@ -64,6 +65,23 @@ export function useSetSkillDetail() {
}); });
} }
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() { export function useEnableSkill() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+31
View File
@@ -2,6 +2,36 @@ import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived"; export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload"; 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 { export interface Skill {
name: string; name: string;
@@ -17,6 +47,7 @@ export interface Skill {
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null; detail?: string | null;
display?: SkillDisplayConfig | null;
// Lifecycle fields — populated by curator. The main list filters on these // Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead. // so stale / archived skills appear in dedicated drawers instead.
state?: SkillState; state?: SkillState;
+4 -1
View File
@@ -13,6 +13,7 @@ import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils"; 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 { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -146,6 +147,7 @@ export function useThreadStream({
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -507,6 +509,7 @@ export function useThreadStream({
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true, memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: localSettings.display.ragModeEnabled,
thread_id: threadId, thread_id: threadId,
}, },
}, },
@@ -520,7 +523,7 @@ export function useThreadStream({
sendInFlightRef.current = false; 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 // Merge thread with optimistic messages for display
+1
View File
@@ -19,6 +19,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean; memory_injection_enabled?: boolean;
rag_mode_enabled?: boolean;
agent_id?: string; agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
@@ -183,8 +183,11 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request
# Convert request to dict format for JSON serialization # Convert request to dict format for JSON serialization
config_data = { config_data = {
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()}, "mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.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 # Write the configuration to file
@@ -1,6 +1,8 @@
import asyncio
import json import json
import logging import logging
import mimetypes import mimetypes
import re
import tempfile import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path 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 app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
from deerflow.config.app_config import AppConfig 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.skills import SkillStore
from deerflow.persistence.tags import TagStore from deerflow.persistence.tags import TagStore
from deerflow.runtime.user_context import get_effective_user_id 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") 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)") 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") 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") 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") 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): class SkillInstallRequest(BaseModel):
"""Request model for installing a skill from a .skill file.""" """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)) builtin = bool(ownership.get("builtin", owner_user_id is None))
detail = ownership.get("detail") 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( return SkillResponse(
name=skill.name, name=skill.name,
description=skill.description, description=skill.description,
@@ -232,9 +256,164 @@ def _skill_to_response(skill: Skill, ownership: dict[str, Any] | None = None) ->
published=published, published=published,
builtin=builtin, builtin=builtin,
detail=detail, 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]]: 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.""" """Index visible-to-user skill records by name. Internal call cache for list endpoints."""
records = await skill_store.list_visible(user_id) 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}") logger.info(f"No existing extensions config found. Creating new config at: {config_path}")
extensions_config = get_extensions_config() 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 = { config_data = _dump_extensions_config(extensions_config)
"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()},
}
with open(config_path, "w", encoding="utf-8") as f: with open(config_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2) 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)}") 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): class SkillDetailRequest(BaseModel):
"""Request body for setting a skill's human-authored detail text.""" """Request body for setting a skill's human-authored detail text."""
@@ -153,6 +153,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"mode", "mode",
"thinking_enabled", "thinking_enabled",
"reasoning_effort", "reasoning_effort",
"rag_mode_enabled",
"writing_mode", "writing_mode",
"writing_artifact_path", "writing_artifact_path",
"canvas_artifact_title", "canvas_artifact_title",
@@ -842,7 +842,10 @@ class DeerFlowClient:
config_data = { config_data = {
"mcpServers": mcp_servers, "mcpServers": mcp_servers,
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()}, "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) self._atomic_write_json(config_path, config_data)
@@ -904,11 +907,18 @@ class DeerFlowClient:
raise FileNotFoundError("Cannot locate extensions_config.json. Set DEER_FLOW_EXTENSIONS_CONFIG_PATH or ensure it exists in the project root.") 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 = get_extensions_config()
extensions_config.skills[name] = SkillStateConfig(enabled=enabled) 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 = { config_data = {
"mcpServers": {n: s.model_dump() for n, s in extensions_config.mcp_servers.items()}, "mcpServers": {n: s.model_dump(by_alias=True) for n, s in extensions_config.mcp_servers.items()},
"skills": {n: {"enabled": sc.enabled} for n, sc in extensions_config.skills.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) self._atomic_write_json(config_path, config_data)
@@ -53,10 +53,63 @@ class McpServerConfig(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True) model_config = ConfigDict(extra="allow", populate_by_name=True)
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): class SkillStateConfig(BaseModel):
"""Configuration for a single skill's state.""" """Configuration for a single skill's state."""
enabled: bool = Field(default=True, description="Whether this skill is enabled") 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): class ExtensionsConfig(BaseModel):