新增 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
9a63a4b7ca
commit
8892637b97
26 files changed
+2732
-470
No files matched your search
@@ -1,183 +1,349 @@
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
deselect();
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatBox };
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const latestAssistant = useMemo(
|
||||
() => findLatestAssistantMessage(thread.values.messages ?? []),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const displaySources = useMemo(() => {
|
||||
if (!settings.display.ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
const messages = thread.values.messages ?? [];
|
||||
if (!latestAssistant?.id) {
|
||||
return [];
|
||||
}
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, latestAssistant.id),
|
||||
beforeMessageId: latestAssistant.id,
|
||||
});
|
||||
}, [settings.display.ragModeEnabled, skillDisplayMap, latestAssistant?.id, thread.values.messages]);
|
||||
const referenceSources = useMemo(
|
||||
() => displaySources.filter((source) => source.mode === "citation"),
|
||||
[displaySources],
|
||||
);
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
}, [hasCitationReferences, latestAssistant?.id]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = () => {
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
return () => {
|
||||
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
deselect();
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedArtifact && hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
} else if (selectedArtifact && !hasCitationReferences) {
|
||||
setSideTab("document");
|
||||
}
|
||||
}, [hasCitationReferences, selectedArtifact]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!sidePanelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{showTabs ? (
|
||||
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel sources={referenceSources} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferencePanel({
|
||||
sources,
|
||||
}: {
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<header className="shrink-0 px-1 pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
参考文献
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{sources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
点击编号或卡片可查看引用详情。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
||||
<SkillDisplaySources sources={sources} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findLatestAssistantMessage(messages: Message[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "ai") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -1,82 +1,78 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const domain = extractDomain(href ?? "");
|
||||
|
||||
// Priority: children > domain
|
||||
const childrenText =
|
||||
typeof children === "string"
|
||||
? children.replace(/^citation:\s*/i, "")
|
||||
: null;
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={0} openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
{displayText}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Badge>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
||||
<div className="p-3">
|
||||
<div className="space-y-1">
|
||||
{displayText && (
|
||||
<h4 className="truncate text-sm leading-tight font-medium">
|
||||
{displayText}
|
||||
</h4>
|
||||
)}
|
||||
{href && (
|
||||
<p className="text-muted-foreground truncate text-xs break-all">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit source
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./i, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const childrenText = normalizeCitationText(children);
|
||||
const displayText = childrenText ?? "";
|
||||
const referenceId = href?.startsWith("#reference-")
|
||||
? href.slice("#reference-".length)
|
||||
: null;
|
||||
|
||||
if (referenceId) {
|
||||
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
title={`查看参考文献 ${badgeLabel}`}
|
||||
className={cn(
|
||||
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||
detail: { referenceId },
|
||||
}),
|
||||
);
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
const scroll = () => {
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
if (target) {
|
||||
scroll();
|
||||
} else {
|
||||
window.setTimeout(scroll, 220);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{badgeLabel}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayText || children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
let text = children.trim();
|
||||
while (/^citation:\s*/i.test(text)) {
|
||||
text = text.replace(/^citation:\s*/i, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
@@ -84,7 +85,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
@@ -175,7 +177,8 @@ export function InputBox({
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
@@ -288,13 +291,18 @@ export function InputBox({
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
@@ -620,8 +628,28 @@ export function InputBox({
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:技能配置的引用展示不会生效"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
|
||||
@@ -47,6 +47,15 @@ import {
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
normalizeDefaultDisplaySources,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -54,6 +63,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -74,6 +84,12 @@ export function MessageGroup({
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -142,7 +158,13 @@ export function MessageGroup({
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
@@ -152,6 +174,8 @@ export function MessageGroup({
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -209,6 +233,8 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -217,11 +243,48 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch = findDisplayConfigForTool(name, skillDisplayMap, args);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={displayMatch?.display.mode === "citation" || !displayMatch ? "检索参考来源" : `获取${displayItems.length}条结果`}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -534,6 +597,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条适配结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
@@ -24,26 +24,31 @@ import {
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { SkillDisplayBlock } from "../skill-display-block";
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
/**
|
||||
@@ -74,14 +79,16 @@ export function MessageListItem({
|
||||
message,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
}) {
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
@@ -94,11 +101,12 @@ export function MessageListItem({
|
||||
>
|
||||
<MessageContent
|
||||
className={isHuman ? "w-fit" : "w-full"}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
/>
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
/>
|
||||
{!isLoading && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
@@ -157,14 +165,16 @@ function MessageContent_({
|
||||
message,
|
||||
isLoading = false,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
}) {
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
@@ -173,9 +183,12 @@ function MessageContent_({
|
||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifact(href);
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
@@ -206,12 +219,12 @@ function MessageContent_({
|
||||
return files as FileInMessage[];
|
||||
}, [message.additional_kwargs?.files, rawContent]);
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 ? (
|
||||
@@ -274,17 +287,17 @@ function MessageContent_({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<MarkdownContent
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
<MessageTokenUsage
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
@@ -459,4 +472,160 @@ function RichFileCard({
|
||||
);
|
||||
}
|
||||
|
||||
const MessageContent = memo(MessageContent_);
|
||||
const MessageContent = memo(MessageContent_);
|
||||
|
||||
function AssistantDisplayContent({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() => splitSkillDisplayPlaceholders(content, displaySources),
|
||||
[content, displaySources],
|
||||
);
|
||||
const fallbackBlocks = useMemo(() => {
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "markdown" ? (
|
||||
<MarkdownContent
|
||||
key={`markdown-${index}`}
|
||||
content={part.content}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<SkillDisplayBlock
|
||||
key={`display-${index}`}
|
||||
mode={part.mode}
|
||||
sources={part.sources}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{fallbackBlocks.map((group) => (
|
||||
<SkillDisplayBlock
|
||||
key={`fallback-${group.key}`}
|
||||
mode={group.mode}
|
||||
sources={group.sources}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayPart =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "display";
|
||||
key: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
function splitSkillDisplayPlaceholders(
|
||||
content: string,
|
||||
displaySources: RuntimeDisplaySource[],
|
||||
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
||||
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
||||
const parts: DisplayPart[] = [];
|
||||
const placedKeys = new Set<string>();
|
||||
let cursor = 0;
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const start = match.index ?? 0;
|
||||
const before = content.slice(cursor, start);
|
||||
if (before.trim()) {
|
||||
parts.push({ type: "markdown", content: before });
|
||||
}
|
||||
const attrs = parseDisplayAttrs(match[1] ?? "");
|
||||
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
||||
if (group) {
|
||||
parts.push({
|
||||
type: "display",
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
});
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
const rest = content.slice(cursor);
|
||||
if (rest.trim() || parts.length === 0) {
|
||||
parts.push({ type: "markdown", content: rest });
|
||||
}
|
||||
return { parts, placedKeys };
|
||||
}
|
||||
|
||||
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
|
||||
attrs[match[1]!] = match[2]!;
|
||||
}
|
||||
return {
|
||||
tool: attrs.tool || attrs.skill || attrs.name,
|
||||
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayGroup(
|
||||
sources: RuntimeDisplaySource[],
|
||||
tool?: string,
|
||||
mode?: RuntimeDisplaySource["mode"],
|
||||
) {
|
||||
return groupDisplaySources(sources).find((group) => {
|
||||
if (mode && group.mode !== mode) {
|
||||
return false;
|
||||
}
|
||||
if (!tool) {
|
||||
return true;
|
||||
}
|
||||
const left = normalizeName(group.skillName);
|
||||
const right = normalizeName(tool);
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
});
|
||||
}
|
||||
|
||||
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
skillName: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
}
|
||||
>();
|
||||
for (const source of sources) {
|
||||
const key = `${source.skillName}:${source.mode}`;
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
group.sources.push(source);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractPresentFilesFromMessage,
|
||||
@@ -19,8 +20,14 @@ import {
|
||||
hasReasoning,
|
||||
hasToolCalls,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -61,9 +68,15 @@ export function MessageList({
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
@@ -179,18 +192,28 @@ export function MessageList({
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
/>
|
||||
);
|
||||
});
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const displaySources =
|
||||
settings.display.ragModeEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai"
|
||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
/>
|
||||
);
|
||||
});
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
@@ -419,15 +442,33 @@ type ClarificationSelection = {
|
||||
custom: string;
|
||||
};
|
||||
|
||||
export type SetupAgentApprovalPayload = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
soul?: string;
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
export type SetupAgentApprovalPayload = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
soul?: string;
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupAgentApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
|
||||
@@ -132,11 +132,26 @@ export function AppearanceSettingsPage() {
|
||||
}
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreviewCard({
|
||||
icon: Icon,
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
mockKnowledgeSearchV2Result,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import { getSkillDisplaySample } from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
["urlField", "URL 跳转字段"],
|
||||
["idField", "ID 跳转字段"],
|
||||
["linkTemplate", "ID 跳转链接模板"],
|
||||
];
|
||||
|
||||
const displayModeLabels: Record<SkillDisplayMode, string> = {
|
||||
citation: "编号引用",
|
||||
list: "列表展示",
|
||||
table: "表格展示",
|
||||
card: "卡片展示",
|
||||
custom: "自定义展示",
|
||||
none: "不展示",
|
||||
};
|
||||
|
||||
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
|
||||
auto: "自动判断",
|
||||
url: "使用 URL 字段跳转",
|
||||
id: "使用 ID 模板跳转",
|
||||
};
|
||||
|
||||
function updateConfig<K extends keyof SkillDisplayConfig>(
|
||||
config: SkillDisplayConfig,
|
||||
key: K,
|
||||
value: SkillDisplayConfig[K],
|
||||
): SkillDisplayConfig {
|
||||
return { ...config, [key]: value };
|
||||
}
|
||||
|
||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [sampleText, setSampleText] = useState(() => JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
// Important: this drives the "保存后回显" behavior. Without depending on
|
||||
// `skill.display`, any backend-side normalization (e.g. default values
|
||||
// backfilled by Pydantic, column reordering) would not flow back into the UI.
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
// Sample text is editor-only state — never reset on save. Only seed it once
|
||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||
// 自动识别 every time they hit 保存.
|
||||
useEffect(() => {
|
||||
setSampleText(JSON.stringify(mockKnowledgeSearchV2Result, null, 2));
|
||||
}, [skill.name]);
|
||||
|
||||
const samplePayload = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(sampleText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
toast.error("样例返回不是有效的 JSON");
|
||||
return;
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
try {
|
||||
const response = await getSkillDisplaySample(skill.name);
|
||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||
setSampleText(nextSampleText);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: draft });
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDisplay() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">技能展示适配</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(displayModeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(linkStrategyLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">最多展示条数</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{fieldLabels.map(([field, label]) => (
|
||||
<label key={field} className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
value={tableColumnsText}
|
||||
onChange={(event) => {
|
||||
setTableColumnsText(event.currentTarget.value);
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
}
|
||||
}}
|
||||
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{draft.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">技能返回样例</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
|
||||
<WandSparklesIcon className="size-3.5" />
|
||||
自动识别
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={sampleText}
|
||||
onChange={(event) => setSampleText(event.currentTarget.value)}
|
||||
className="min-h-72 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">展示预览</div>
|
||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
{!samplePayload ? (
|
||||
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
||||
) : previewItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||
) : draft.mode === "citation" ? (
|
||||
<>
|
||||
<p className="text-sm leading-relaxed">
|
||||
编号引用预览:
|
||||
{previewItems.slice(0, 3).map((_item, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={idx}
|
||||
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
|
||||
>
|
||||
[{idx + 1}]
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
{previewItems.map((item, idx) => (
|
||||
<PreviewItem key={idx} index={idx + 1} item={item} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
|
||||
清除
|
||||
</Button>
|
||||
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
|
||||
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
保存展示适配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewItem({
|
||||
index,
|
||||
item,
|
||||
}: {
|
||||
index: number;
|
||||
item: ReturnType<typeof normalizeDisplaySources>[number];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background p-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
[{index}] {item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type}
|
||||
{item.source ? ` | ${item.source}` : ""}
|
||||
{item.time ? ` | ${item.time}` : ""}
|
||||
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="打开链接"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{item.snippet || item.content || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { SkillDisplayTableColumn } from "@/core/skills/type";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SourceCard } from "./skill-display-sources";
|
||||
|
||||
export function SkillDisplayBlock({
|
||||
mode,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0 || mode === "citation" || mode === "none") {
|
||||
return null;
|
||||
}
|
||||
if (mode === "table") {
|
||||
return <SkillDisplayTable sources={sources} className={className} />;
|
||||
}
|
||||
return (
|
||||
<div className={cn("not-prose my-4 grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDisplayTable({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
|
||||
const columns = useMemo(() => buildColumns(sources), [sources]);
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.field}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
|
||||
onClick={() => setSelected(source)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
|
||||
<span className="line-clamp-2 break-words">
|
||||
{formatCell(readByPath(source.raw, column.field))}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDetailDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
|
||||
{JSON.stringify(source.raw, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
|
||||
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
const raw = sources[0]?.raw ?? {};
|
||||
return Object.keys(raw)
|
||||
.slice(0, 6)
|
||||
.map((field) => ({ label: field, field }));
|
||||
}
|
||||
|
||||
function readByPath(value: unknown, path: string): unknown {
|
||||
if (!path) {
|
||||
return value;
|
||||
}
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
source={source}
|
||||
referenceTarget
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceCard({
|
||||
source,
|
||||
referenceTarget = false,
|
||||
}: {
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = source.content || source.snippet;
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
? `${Math.round(source.score * 100)}%`
|
||||
: String(source.score)
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:bg-primary/5 target:border-primary/60",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
|
||||
{source.title}
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="inline-flex items-center">
|
||||
{index > 0 && <span className="mr-2 opacity-50">·</span>}
|
||||
<span className="truncate">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{content && (
|
||||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||||
{content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{isCitation && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[55vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
||||
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
||||
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -87,7 +89,7 @@ export function SkillDetailDialog({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "source" | "files" | null
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||
@@ -206,6 +208,12 @@ export function SkillDetailDialog({
|
||||
<FileTextIcon className="mr-1 size-3.5" />
|
||||
{tx.detailSectionTitle}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="display" className="text-xs">
|
||||
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canManage && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
@@ -329,6 +337,16 @@ export function SkillDetailDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="display"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<SkillDisplayAdapterPanel skill={skill} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
|
||||
Reference in new issue
Block a user