diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index dec47e42..ce37986a 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -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(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 ( - - - {children} - - - -
- {selectedArtifact ? ( - - ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- )} -
-
-
- ); -}; - -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(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 ( + + + {children} + + + +
+ {showTabs ? ( + +
+ + 文档 + 参考文献 + + +
+ + + + + + +
+ ) : selectedArtifact ? ( + + ) : hasCitationReferences ? ( +
+
+ +
+ +
+ ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ )} +
+
+
+ ); +}; + +function ReferencePanel({ + sources, +}: { + sources: Parameters[0]["sources"]; +}) { + return ( +
+
+
+

+ 参考文献 +

+ + {sources.length} 条 + +
+

+ 点击编号或卡片可查看引用详情。 +

+
+
+ +
+
+ ); +} + +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 }; diff --git a/frontend-web/src/components/workspace/citations/citation-link.tsx b/frontend-web/src/components/workspace/citations/citation-link.tsx index aecc71a8..58b7c22a 100644 --- a/frontend-web/src/components/workspace/citations/citation-link.tsx +++ b/frontend-web/src/components/workspace/citations/citation-link.tsx @@ -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 ( - - - e.stopPropagation()} - {...props} - > - - {displayText} - - - - - -
-
- {displayText && ( -

- {displayText} -

- )} - {href && ( -

- {href} -

- )} -
- - Visit source - - -
-
-
- ); -} - -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 ( + { + 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} + + ); + } + + return ( + + {displayText || children} + + ); +} + +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; +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index d547bd75..43640eeb 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -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({ 记忆 - - + + + + + 参考文献 + + + 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({ } > ) : ( - + ), )} {lastToolCallStep && ( @@ -152,6 +174,8 @@ export function MessageGroup({ {...lastToolCallStep} isLast={true} isLoading={isLoading} + skillDisplayMap={skillDisplayMap} + ragModeEnabled={settings.display.ragModeEnabled} /> )} @@ -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; isLast?: boolean; isLoading?: boolean; + skillDisplayMap?: ReturnType; + 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 ( + + + + ); + } + 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 ( + + + + ); + } 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 ( + + + + {results.length} 条适配结果 + + + {results.map((item) => ( + + ))} + + + ); +} + function SearchResultCard({ item }: { item: SearchResultItem }) { const [open, setOpen] = useState(false); const content = item.snippet ?? item.content; diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index c6bdcfe8..11238584 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -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({ > + message={message} + isLoading={isLoading} + threadId={threadId} + tokenUsageEnabled={tokenUsageEnabled} + displaySources={displaySources} + /> {!isLoading && ( ) => ( ), - a: ({ href, ...props }: AnchorHTMLAttributes) => { - if (href?.startsWith("/mnt/")) { - const url = resolveArtifact(href); + a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (typeof props.children === "string" && /^citation:/i.test(props.children)) { + return ; + } + if (href?.startsWith("/mnt/")) { + const url = resolveArtifact(href); return ( { - 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 ( - - {filesList} - - + {filesList} + + 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" ? ( + + ) : ( + + ), + )} + {fallbackBlocks.map((group) => ( + + ))} + + ); +} + +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 } { + const pattern = /]*?)\/>/g; + const parts: DisplayPart[] = []; + const placedKeys = new Set(); + 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 = {}; + 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, ""); +} diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 63cee2fb..1624d7c7 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -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; }) { 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({ {groupMessages(messages, (group) => { - if (group.type === "human" || group.type === "assistant") { - return group.messages.map((msg) => { - return ( - - ); - }); + 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 ( + + ); + }); } 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; diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 0d09f5e1..9a853cbe 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -132,11 +132,26 @@ export function AppearanceSettingsPage() { } aria-label="回答速度指示器" /> - - - - ); -} + +
+
+
RAG 引用模式
+

+ 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 +

+
+ + setSettings("display", { ragModeEnabled: checked }) + } + aria-label="RAG 引用模式" + /> +
+ + + ); +} function ThemePreviewCard({ icon: Icon, diff --git a/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx new file mode 100644 index 00000000..ed8d2071 --- /dev/null +++ b/frontend-web/src/components/workspace/skill-display-adapter-panel.tsx @@ -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 = { + citation: "编号引用", + list: "列表展示", + table: "表格展示", + card: "卡片展示", + custom: "自定义展示", + none: "不展示", +}; + +const linkStrategyLabels: Record = { + auto: "自动判断", + url: "使用 URL 字段跳转", + id: "使用 ID 模板跳转", +}; + +function updateConfig( + 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(() => 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 ( +
+
+
+
+
技能展示适配
+

+ 从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。 +

+
+
+ 启用 + setDraft(updateConfig(draft, "enabled", checked))} + /> +
+
+ +
+ + + + + +
+ +
+ {fieldLabels.map(([field, label]) => ( + + ))} +
+ + {draft.mode === "table" && ( +