新增 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
+2313
-330
No files matched your search
@@ -1,183 +1,349 @@
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
deselect();
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatBox };
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const latestAssistant = useMemo(
|
||||
() => findLatestAssistantMessage(thread.values.messages ?? []),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const displaySources = useMemo(() => {
|
||||
if (!settings.display.ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
const messages = thread.values.messages ?? [];
|
||||
if (!latestAssistant?.id) {
|
||||
return [];
|
||||
}
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
|
||||
beforeMessageId: latestAssistant.id,
|
||||
});
|
||||
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]);
|
||||
const referenceSources = useMemo(
|
||||
() => displaySources.filter((source) => source.mode === "citation"),
|
||||
[displaySources],
|
||||
);
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
}, [hasCitationReferences, latestAssistant?.id]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = () => {
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
return () => {
|
||||
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
deselect();
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedArtifact && hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
} else if (selectedArtifact && !hasCitationReferences) {
|
||||
setSideTab("document");
|
||||
}
|
||||
}, [hasCitationReferences, selectedArtifact]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!sidePanelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{showTabs ? (
|
||||
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferencePanel({
|
||||
sources,
|
||||
}: {
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<header className="shrink-0 px-1 pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
参考文献
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{sources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
点击编号或卡片可查看引用详情。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
||||
<SkillDisplaySources sources={sources} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAssistantMessage(messages: Message[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "ai") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -1,82 +1,78 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const domain = extractDomain(href ?? "");
|
||||
|
||||
// Priority: children > domain
|
||||
const childrenText =
|
||||
typeof children === "string"
|
||||
? children.replace(/^citation:\s*/i, "")
|
||||
: null;
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={0} openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
{displayText}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Badge>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
||||
<div className="p-3">
|
||||
<div className="space-y-1">
|
||||
{displayText && (
|
||||
<h4 className="truncate text-sm leading-tight font-medium">
|
||||
{displayText}
|
||||
</h4>
|
||||
)}
|
||||
{href && (
|
||||
<p className="text-muted-foreground truncate text-xs break-all">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit source
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./i, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const childrenText = normalizeCitationText(children);
|
||||
const displayText = childrenText ?? "";
|
||||
const referenceId = href?.startsWith("#reference-")
|
||||
? href.slice("#reference-".length)
|
||||
: null;
|
||||
|
||||
if (referenceId) {
|
||||
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
title={`查看参考文献 ${badgeLabel}`}
|
||||
className={cn(
|
||||
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||
detail: { referenceId },
|
||||
}),
|
||||
);
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
const scroll = () => {
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
if (target) {
|
||||
scroll();
|
||||
} else {
|
||||
window.setTimeout(scroll, 220);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{badgeLabel}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayText || children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
let text = children.trim();
|
||||
while (/^citation:\s*/i.test(text)) {
|
||||
text = text.replace(/^citation:\s*/i, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
@@ -85,7 +86,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
@@ -176,7 +178,8 @@ export function InputBox({
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
@@ -289,13 +292,18 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
@@ -621,8 +629,28 @@ export function InputBox({
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:技能配置的引用展示不会生效"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
|
||||
@@ -47,6 +47,15 @@ import {
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
normalizeDefaultDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -54,6 +63,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -74,6 +84,12 @@ export function MessageGroup({
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -142,7 +158,13 @@ export function MessageGroup({
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
@@ -152,6 +174,8 @@ export function MessageGroup({
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -209,6 +233,8 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -217,11 +243,48 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -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 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
@@ -124,11 +124,26 @@ export function AppearanceSettingsPage() {
|
||||
}
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
mockKnowledgeSearchV2Result,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import { getSkillDisplaySample } from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
["urlField", "URL 跳转字段"],
|
||||
["idField", "ID 跳转字段"],
|
||||
["linkTemplate", "ID 跳转链接模板"],
|
||||
];
|
||||
|
||||
const displayModeLabels: Record<SkillDisplayMode, string> = {
|
||||
citation: "编号引用",
|
||||
list: "列表展示",
|
||||
table: "表格展示",
|
||||
card: "卡片展示",
|
||||
custom: "自定义展示",
|
||||
none: "不展示",
|
||||
};
|
||||
|
||||
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
|
||||
auto: "自动判断",
|
||||
url: "使用 URL 字段跳转",
|
||||
id: "使用 ID 模板跳转",
|
||||
};
|
||||
|
||||
function updateConfig<K extends keyof SkillDisplayConfig>(
|
||||
config: SkillDisplayConfig,
|
||||
key: K,
|
||||
value: SkillDisplayConfig[K],
|
||||
): SkillDisplayConfig {
|
||||
return { ...config, [key]: value };
|
||||
}
|
||||
|
||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
// Important: this drives the "保存后回显" behavior. Without depending on
|
||||
// `skill.display`, any backend-side normalization (e.g. default values
|
||||
// backfilled by Pydantic, column reordering) would not flow back into the UI.
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
// Sample text is editor-only state — never reset on save. Only seed it once
|
||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||
// 自动识别 every time they hit 保存.
|
||||
useEffect(() => {
|
||||
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
||||
}, [skill.name]);
|
||||
|
||||
const samplePayload = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(sampleText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
toast.error("样例返回不是有效的 JSON");
|
||||
return;
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
try {
|
||||
const response = await getSkillDisplaySample(skill.name);
|
||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||
setSampleText(nextSampleText);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDisplay() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">技能展示适配</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(displayModeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(linkStrategyLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">最多展示条数</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{fieldLabels.map(([field, label]) => (
|
||||
<label key={field} className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
value={tableColumnsText}
|
||||
onChange={(event) => {
|
||||
setTableColumnsText(event.currentTarget.value);
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
}
|
||||
}}
|
||||
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{draft.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">技能返回样例</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
|
||||
<WandSparklesIcon className="size-3.5" />
|
||||
自动识别
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={sampleText}
|
||||
onChange={(event) => setSampleText(event.currentTarget.value)}
|
||||
className="min-h-72 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">展示预览</div>
|
||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
{!samplePayload ? (
|
||||
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
||||
) : previewItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||
) : draft.mode === "citation" ? (
|
||||
<>
|
||||
<p className="text-sm leading-relaxed">
|
||||
编号引用预览:
|
||||
{previewItems.slice(0, 3).map((_item, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={idx}
|
||||
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
|
||||
>
|
||||
[{idx + 1}]
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
{previewItems.map((item, idx) => (
|
||||
<PreviewItem key={idx} index={idx + 1} item={item} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
|
||||
清除
|
||||
</Button>
|
||||
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
|
||||
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
保存展示适配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewItem({
|
||||
index,
|
||||
item,
|
||||
}: {
|
||||
index: number;
|
||||
item: ReturnType<typeof normalizeDisplaySources>[number];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background p-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
[{index}] {item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type}
|
||||
{item.source ? ` | ${item.source}` : ""}
|
||||
{item.time ? ` | ${item.time}` : ""}
|
||||
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="打开链接"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{item.snippet || item.content || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { SkillDisplayTableColumn } from "@/core/skills/type";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SourceCard } from "./skill-display-sources";
|
||||
|
||||
export function SkillDisplayBlock({
|
||||
mode,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0 || mode === "citation" || mode === "none") {
|
||||
return null;
|
||||
}
|
||||
if (mode === "table") {
|
||||
return <SkillDisplayTable sources={sources} className={className} />;
|
||||
}
|
||||
return (
|
||||
<div className={cn("not-prose my-4 grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDisplayTable({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
|
||||
const columns = useMemo(() => buildColumns(sources), [sources]);
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.field}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
|
||||
onClick={() => setSelected(source)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
|
||||
<span className="line-clamp-2 break-words">
|
||||
{formatCell(readByPath(source.raw, column.field))}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDetailDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
|
||||
{JSON.stringify(source.raw, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
|
||||
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
const raw = sources[0]?.raw ?? {};
|
||||
return Object.keys(raw)
|
||||
.slice(0, 6)
|
||||
.map((field) => ({ label: field, field }));
|
||||
}
|
||||
|
||||
function readByPath(value: unknown, path: string): unknown {
|
||||
if (!path) {
|
||||
return value;
|
||||
}
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
source={source}
|
||||
referenceTarget
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceCard({
|
||||
source,
|
||||
referenceTarget = false,
|
||||
}: {
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = source.content || source.snippet;
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
? `${Math.round(source.score * 100)}%`
|
||||
: String(source.score)
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:bg-primary/5 target:border-primary/60",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
|
||||
{source.title}
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="inline-flex items-center">
|
||||
{index > 0 && <span className="mr-2 opacity-50">·</span>}
|
||||
<span className="truncate">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||||
{content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
||||
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
||||
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -88,7 +90,7 @@ export function SkillDetailDialog({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "source" | "files" | null
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||
@@ -207,6 +209,12 @@ export function SkillDetailDialog({
|
||||
<FileTextIcon className="mr-1 size-3.5" />
|
||||
{tx.detailSectionTitle}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="display" className="text-xs">
|
||||
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canManage && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
@@ -330,6 +338,16 @@ export function SkillDetailDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="display"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<SkillDisplayAdapterPanel skill={skill} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
|
||||
@@ -4,9 +4,10 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: true,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -32,8 +33,13 @@ export interface LocalSettings {
|
||||
* AI reply is in flight. Default on; controlled by the user from
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
};
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
/**
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
export interface DisplaySourceItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
enabled: false,
|
||||
mode: "none",
|
||||
resultPath: "results",
|
||||
titleField: null,
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: 8,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
export const mockKnowledgeSearchV2Result = {
|
||||
success: true,
|
||||
query: "latest news",
|
||||
count: 3,
|
||||
results: [
|
||||
{
|
||||
index: 1,
|
||||
recUuid: "rec-001",
|
||||
relevance_score: 0.982,
|
||||
title: "Knowledge result one",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
publish_time: "2026-05-25",
|
||||
content_preview: "Mock result used to verify citation numbers, source cards, and recUuid template links.",
|
||||
content_length: 1200,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
recUuid: "rec-002",
|
||||
relevance_score: 0.951,
|
||||
title: "Knowledge result two",
|
||||
source_category: "knowledge/news",
|
||||
source: "internal news library",
|
||||
publish_time: "2026-05-24",
|
||||
content_preview: "Mock result used to verify citation ordering and detail preview.",
|
||||
content_length: 980,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
recUuid: "rec-003",
|
||||
relevance_score: 0.921,
|
||||
title: "Knowledge result three",
|
||||
source_category: "knowledge/commentary",
|
||||
source: "internal knowledge library",
|
||||
publish_time: "2026-05-23",
|
||||
content_preview: "Mock result used to verify list mode and custom display instruction previews.",
|
||||
content_length: 760,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const candidateFields: Record<string, string[]> = {
|
||||
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
urlField: ["url", "link", "href", "source_url"],
|
||||
idField: ["recUuid", "id", "uuid", "docId", "taskId", "recordId"],
|
||||
};
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
if (!path) return value;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function findFirstArray(value: unknown, path = ""): { path: string; items: Record<string, unknown>[] } | null {
|
||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||
return { path, items: value as Record<string, unknown>[] };
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||
const keys = Object.keys(sample);
|
||||
for (const candidate of candidates) {
|
||||
const exact = keys.find((key) => key === candidate);
|
||||
if (exact) return exact;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||
const sample = found.items[0] ?? {};
|
||||
const inferred = emptyDisplayConfig();
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferred.mode === "citation" ? 8 : 20;
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
}
|
||||
|
||||
if (skillName === "knowledge-search-v2") {
|
||||
inferred.titleField = inferred.titleField || "title";
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
inferred.idField = inferred.idField || "recUuid";
|
||||
inferred.linkStrategy = "id";
|
||||
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||
}
|
||||
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildDefaultTableColumns(
|
||||
sample: Record<string, unknown>,
|
||||
config: SkillDisplayConfig,
|
||||
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||
const preferred = [
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
const fallback = Object.keys(sample)
|
||||
.filter((field) => !preferred.includes(field))
|
||||
.slice(0, Math.max(0, 6 - preferred.length));
|
||||
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||
label: field,
|
||||
field,
|
||||
}));
|
||||
}
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) => encodeURIComponent(stringify(item[key])));
|
||||
}
|
||||
|
||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||
if (config.linkStrategy === "url") return url || undefined;
|
||||
if (config.linkStrategy === "id") return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||
}
|
||||
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
return rawItems
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, config.maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `Result ${index + 1}`) : `Result ${index + 1}`,
|
||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score: config.scoreField && typeof item[config.scoreField] === "number" ? item[config.scoreField] as number : undefined,
|
||||
url: resolveLink(item, config),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { normalizeSearchResults } from "@/core/messages/search-results";
|
||||
import type { Skill, SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
import {
|
||||
normalizeDisplaySources,
|
||||
type DisplaySourceItem,
|
||||
} from "./display-adapter";
|
||||
|
||||
export type RuntimeDisplaySource = DisplaySourceItem & {
|
||||
index: number;
|
||||
skillName: string;
|
||||
mode: SkillDisplayConfig["mode"];
|
||||
tableColumns?: SkillDisplayConfig["tableColumns"];
|
||||
};
|
||||
|
||||
export type SkillDisplayMap = Map<string, SkillDisplayConfig>;
|
||||
|
||||
export function buildSkillDisplayMap(skills: Skill[]): SkillDisplayMap {
|
||||
const map = new Map<string, SkillDisplayConfig>();
|
||||
for (const skill of skills) {
|
||||
const display = skill.display;
|
||||
if (!display?.enabled || display.mode === "none") {
|
||||
continue;
|
||||
}
|
||||
map.set(skill.name, display);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function findDisplayConfigForTool(
|
||||
toolName: string,
|
||||
displays: SkillDisplayMap,
|
||||
toolArgs?: Record<string, unknown>,
|
||||
): { skillName: string; display: SkillDisplayConfig } | null {
|
||||
const exact = displays.get(toolName);
|
||||
if (exact) {
|
||||
return { skillName: toolName, display: exact };
|
||||
}
|
||||
|
||||
const normalizedTool = normalizeName(toolName);
|
||||
for (const [skillName, display] of displays) {
|
||||
const normalizedSkill = normalizeName(skillName);
|
||||
if (
|
||||
normalizedTool === normalizedSkill ||
|
||||
normalizedTool.endsWith(normalizedSkill) ||
|
||||
normalizedTool.includes(normalizedSkill)
|
||||
) {
|
||||
return { skillName, display };
|
||||
}
|
||||
}
|
||||
|
||||
// bash/python skill invocations don't carry the skill name in the tool name —
|
||||
// the path lives inside the command/args (e.g. `python3 skills/custom/demo/scripts/run.py`).
|
||||
// Scan stringy args for the `skills/{public,custom}/<name>/` shape so display
|
||||
// configs still resolve. Mirrors the backend `_SKILL_PATH_RE` in
|
||||
// tool_metrics_middleware.py.
|
||||
if (toolArgs) {
|
||||
const skillFromArgs = extractSkillNameFromArgs(toolArgs);
|
||||
if (skillFromArgs) {
|
||||
const direct = displays.get(skillFromArgs);
|
||||
if (direct) {
|
||||
return { skillName: skillFromArgs, display: direct };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const SKILL_PATH_RE = /[\\/]skills[\\/](?:public|custom)[\\/]([^\\/]+)(?:[\\/]|$)/;
|
||||
|
||||
function extractSkillNameFromArgs(args: Record<string, unknown>): string | undefined {
|
||||
for (const value of Object.values(args)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const match = SKILL_PATH_RE.exec(value);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function collectRuntimeDisplaySources(
|
||||
messages: Message[],
|
||||
displays: SkillDisplayMap,
|
||||
options: {
|
||||
afterMessageId?: string;
|
||||
beforeMessageId?: string;
|
||||
includeModes?: SkillDisplayConfig["mode"][];
|
||||
} = {},
|
||||
): RuntimeDisplaySource[] {
|
||||
const start =
|
||||
options.afterMessageId === undefined
|
||||
? 0
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.afterMessageId) + 1,
|
||||
0,
|
||||
);
|
||||
const limit =
|
||||
options.beforeMessageId === undefined
|
||||
? messages.length
|
||||
: Math.max(
|
||||
messages.findIndex((message) => message.id === options.beforeMessageId),
|
||||
0,
|
||||
);
|
||||
const allowedModes = options.includeModes
|
||||
? new Set(options.includeModes)
|
||||
: null;
|
||||
const sources: RuntimeDisplaySource[] = [];
|
||||
let citationIndex = 0;
|
||||
const displayIndexes = new Map<string, number>();
|
||||
|
||||
for (let index = start; index < limit; index++) {
|
||||
const message = messages[index];
|
||||
if (!message || message.type !== "ai") {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
const match = findDisplayConfigForTool(
|
||||
toolCall.name,
|
||||
displays,
|
||||
toolCall.args as Record<string, unknown> | undefined,
|
||||
);
|
||||
const effectiveMode = match?.display.mode ?? "citation";
|
||||
if (allowedModes && !allowedModes.has(effectiveMode)) {
|
||||
continue;
|
||||
}
|
||||
const result = findToolResultPayload(toolCall.id, messages);
|
||||
if (result === undefined) {
|
||||
continue;
|
||||
}
|
||||
const normalized = match
|
||||
? normalizeDisplaySources(result, match.display)
|
||||
: normalizeDefaultDisplaySources(result);
|
||||
for (const item of normalized) {
|
||||
const skillName = match?.skillName ?? toolCall.name;
|
||||
const displayKey = `${skillName}:${effectiveMode}`;
|
||||
const displayIndex = (displayIndexes.get(displayKey) ?? 0) + 1;
|
||||
displayIndexes.set(displayKey, displayIndex);
|
||||
sources.push({
|
||||
...item,
|
||||
index: effectiveMode === "citation" ? ++citationIndex : displayIndex,
|
||||
skillName,
|
||||
mode: effectiveMode,
|
||||
tableColumns: match?.display.tableColumns,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function normalizeDefaultDisplaySources(payload: unknown): DisplaySourceItem[] {
|
||||
const normalized = normalizeSearchResults(
|
||||
typeof payload === "string" || isRecord(payload)
|
||||
? (payload as string | Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
return normalized.map((item, index) => ({
|
||||
id: item.url ?? String(index + 1),
|
||||
title: item.title ?? item.source ?? `结果 ${index + 1}`,
|
||||
type: "result",
|
||||
snippet: item.snippet ?? item.content ?? "",
|
||||
content: item.content ?? item.snippet,
|
||||
source: item.source,
|
||||
time: item.published_date ?? item.publishedDate ?? item.date,
|
||||
url: item.url,
|
||||
raw: item as unknown as Record<string, unknown>,
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyInlineCitations(
|
||||
content: string,
|
||||
sources: RuntimeDisplaySource[],
|
||||
): string {
|
||||
if (!content || sources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const citationSources = sources.filter((source) => source.mode === "citation");
|
||||
if (citationSources.length === 0) {
|
||||
return content;
|
||||
}
|
||||
const byIndex = new Map(citationSources.map((source) => [source.index, source]));
|
||||
const byUrl = new Map<string, RuntimeDisplaySource>();
|
||||
const byTitle = new Map<string, RuntimeDisplaySource>();
|
||||
for (const source of citationSources) {
|
||||
if (source.url) {
|
||||
byUrl.set(source.url, source);
|
||||
}
|
||||
if (source.title) {
|
||||
byTitle.set(source.title.trim(), source);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeExistingCitationLinks(content, byIndex, byUrl, byTitle);
|
||||
|
||||
const markdownLinkPattern = /!?\[(?:[^\[\]]|\[[^\]]*\])*\]\([^)]+\)/g;
|
||||
let cursor = 0;
|
||||
let next = "";
|
||||
for (const match of normalized.matchAll(markdownLinkPattern)) {
|
||||
const start = match.index ?? 0;
|
||||
next += replaceCitationTokens(normalized.slice(cursor, start), byIndex);
|
||||
next += match[0];
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
next += replaceCitationTokens(normalized.slice(cursor), byIndex);
|
||||
return next;
|
||||
}
|
||||
|
||||
function replaceCitationTokens(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
): string {
|
||||
return content
|
||||
.replace(/\[\[(\d+)\]\]/g, (match, rawIndex: string) =>
|
||||
citationMarkdown(match, Number(rawIndex), byIndex),
|
||||
)
|
||||
.replace(/(^|[^:\[!])\[(\d+)\](?!\()/g, (match, prefix: string, rawIndex: string) =>
|
||||
`${prefix}${citationMarkdown(match.slice(prefix.length), Number(rawIndex), byIndex)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeExistingCitationLinks(
|
||||
content: string,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
byUrl: Map<string, RuntimeDisplaySource>,
|
||||
byTitle: Map<string, RuntimeDisplaySource>,
|
||||
): string {
|
||||
// Resolve every `[citation:LABEL](URL)` link — whether LABEL is a number
|
||||
// ("[1]", "1") or a free-form title — to a numbered reference link that
|
||||
// CitationLink renders as a badge and scrolls to the right-side panel.
|
||||
return content.replace(
|
||||
/\[citation:([^\]]+)\]\(([^)]+)\)(?:\([^)]+\))*/g,
|
||||
(match, rawLabel: string, rawUrl: string) => {
|
||||
const label = rawLabel.trim();
|
||||
const url = rawUrl.trim();
|
||||
const numberMatch = label.match(/^\[?(\d+)\]?$/);
|
||||
if (numberMatch) {
|
||||
const index = Number(numberMatch[1]);
|
||||
if (byIndex.has(index)) {
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
}
|
||||
}
|
||||
const sourceByUrl = byUrl.get(url);
|
||||
if (sourceByUrl) {
|
||||
return `[citation:[${sourceByUrl.index}]](#reference-${sourceByUrl.index})`;
|
||||
}
|
||||
const sourceByTitle = byTitle.get(label);
|
||||
if (sourceByTitle) {
|
||||
return `[citation:[${sourceByTitle.index}]](#reference-${sourceByTitle.index})`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function citationMarkdown(
|
||||
fallback: string,
|
||||
index: number,
|
||||
byIndex: Map<number, RuntimeDisplaySource>,
|
||||
): string {
|
||||
const source = byIndex.get(index);
|
||||
if (!source) {
|
||||
return fallback;
|
||||
}
|
||||
return `[citation:[${index}]](#reference-${index})`;
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function findToolResultPayload(
|
||||
toolCallId: string | undefined,
|
||||
messages: Message[],
|
||||
): unknown {
|
||||
if (!toolCallId) {
|
||||
return undefined;
|
||||
}
|
||||
const toolMessage = messages.find(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
if (!toolMessage) {
|
||||
return undefined;
|
||||
}
|
||||
const content = extractToolText(toolMessage.content);
|
||||
if (!content) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(content) as unknown;
|
||||
} catch {
|
||||
// bash stdout on the local sandbox can be suffixed with
|
||||
// "\nStd Error: ..." / "\nExit Code: N" (see local_sandbox.execute_command),
|
||||
// and skills sometimes print informational lines around their JSON payload.
|
||||
// Salvage the first balanced JSON object or array we can find before
|
||||
// falling back to the raw string.
|
||||
const recovered = extractJSONFromText(content);
|
||||
if (recovered !== undefined) {
|
||||
return recovered;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJSONFromText(text: string): unknown {
|
||||
const candidates: Array<[number, number]> = [];
|
||||
const firstObj = text.indexOf("{");
|
||||
if (firstObj !== -1) candidates.push([firstObj, text.lastIndexOf("}")]);
|
||||
const firstArr = text.indexOf("[");
|
||||
if (firstArr !== -1) candidates.push([firstArr, text.lastIndexOf("]")]);
|
||||
candidates.sort(([a], [b]) => a - b);
|
||||
for (const [start, end] of candidates) {
|
||||
if (start < 0 || end <= start) continue;
|
||||
const slice = text.slice(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractToolText(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content.trim();
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return part;
|
||||
}
|
||||
if (part && typeof part === "object" && "text" in part) {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillState } from "./type";
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
@@ -30,7 +30,7 @@ export async function getSkill(skillName: string): Promise<Skill> {
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||
{
|
||||
@@ -44,7 +44,45 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
},
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||
}
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
getSkillFiles,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillPinned,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
@@ -52,7 +53,7 @@ export function useSkill(skillName: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDetail() {
|
||||
export function useSetSkillDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
||||
@@ -62,7 +63,24 @@ export function useSetSkillDetail() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
}) => setSkillDisplay(skillName, display),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -2,6 +2,36 @@ import type { Tag } from "@/core/tags";
|
||||
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
|
||||
export type SkillLinkStrategy = "auto" | "url" | "id";
|
||||
export type SkillCitationToken = "doubleBracket" | "singleBracket";
|
||||
|
||||
export interface SkillDisplayTableColumn {
|
||||
label: string;
|
||||
field: string;
|
||||
width?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillDisplayConfig {
|
||||
enabled: boolean;
|
||||
mode: SkillDisplayMode;
|
||||
resultPath: string;
|
||||
titleField?: string | null;
|
||||
summaryField?: string | null;
|
||||
contentField?: string | null;
|
||||
sourceField?: string | null;
|
||||
typeField?: string | null;
|
||||
timeField?: string | null;
|
||||
scoreField?: string | null;
|
||||
urlField?: string | null;
|
||||
idField?: string | null;
|
||||
linkStrategy: SkillLinkStrategy;
|
||||
linkTemplate?: string | null;
|
||||
citationToken: SkillCitationToken;
|
||||
maxItems: number;
|
||||
customInstruction: string;
|
||||
tableColumns?: SkillDisplayTableColumn[];
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -17,6 +47,7 @@ export interface Skill {
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
display?: SkillDisplayConfig | null;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
|
||||
@@ -12,7 +12,8 @@ import { apiFetch } from "../api";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { useLocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -145,7 +146,8 @@ export function useThreadStream({
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -506,8 +508,9 @@ export function useThreadStream({
|
||||
: context.mode === "thinking"
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
thread_id: threadId,
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: localSettings.display.ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -520,8 +523,8 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
||||
);
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
const mergedThread =
|
||||
|
||||
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user