新增 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:
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,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 "";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user