import type { Message } from "@langchain/langgraph-sdk"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { FilesIcon, XIcon } from "lucide-react"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, useLayoutEffect, 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 { apiFetch } from "@/core/api"; import { artifactPathname, listThreadArtifacts, mergeArtifactPaths, } from "@/core/artifacts/listing"; import { latestCompletedArtifactMutationKey, latestInFlightWriteFilePreview, latestPreparedHtmlArtifact, isWriteFileInFlight, } from "@/core/artifacts/write-file-in-flight"; import { isAutoPreviewableArtifactPath, languageOfStreamingArtifact, } from "@/core/artifacts/streaming-write-preview"; import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; import { useIsMobile } from "@/hooks/use-mobile"; import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { useSkills } from "@/core/skills/hooks"; import type { SkillDisplayConfig } from "@/core/skills/type"; import { displayConfigFromResultSet, normalizeDisplaySources, } from "@/core/skill-display/display-adapter"; import { buildSkillDisplayMap, collectRuntimeDisplaySources, } from "@/core/skill-display/runtime"; import { isWordDocumentFile } from "@/core/utils/files"; import { env } from "@/env"; import { cn } from "@/lib/utils"; import { ArtifactFileDetail, ArtifactDirectory, ArtifactFileList, useArtifacts, } from "../artifacts"; import { ReferenceBatchesContext, useThread } from "../messages/context"; import { KnowledgeWikiPanel } from "../knowledge-wiki-panel"; import { SkillDisplaySources } from "../skill-display-sources"; const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 }; const SHOW_REFERENCE_EVENT = "workspace:show-reference"; type BackendReferenceSource = Parameters[0]["sources"][number]; type BackendReferenceBatch = { id: string; assistant_message_id?: string; sources: BackendReferenceSource[]; }; function logReferenceDebug( event: string, threadId: string, payload: Record, ) { console.info(`[RAG引用调试] ${event}`, payload); void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ event, thread_id: threadId, payload, }), }).catch(() => { // Debug logging must never affect the chat flow. }); } const ChatBox: React.FC<{ children: React.ReactNode; threadId: string; disableArtifacts?: boolean; writingMode?: boolean; /** Prefer and automatically reveal a specific artifact once it is available. */ preferredArtifact?: string | null; /** Keep scanning until the preferred artifact appears (for delayed file persistence). */ pollForPreferredArtifact?: boolean; /** Report the real artifact discovered on disk back to the page state. */ onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void; /** Stack the artifact below chat on small screens. */ responsiveArtifactLayout?: boolean; }> = ({ children, threadId, disableArtifacts = false, writingMode, preferredArtifact = null, pollForPreferredArtifact = false, onPreferredArtifactAvailabilityChange, responsiveArtifactLayout = false, }) => { const { isMock, thread, knowledgeReferencesEnabled = false, knowledgeReferences, } = useThread(); const pathname = usePathname(); const searchParams = useSearchParams(); const requestedArtifact = useMemo(() => { const value = searchParams.get("artifact")?.trim(); return value ? artifactPathname(value) : null; }, [searchParams]); const threadIdRef = useRef(threadId); const openedRequestedArtifactRef = useRef(null); const openedPreferredArtifactRef = useRef(null); const preferredRunWasLoadingRef = useRef(false); const artifactsCountRef = useRef(0); const layoutRef = useRef(null); const latestReferenceBatchIdRef = useRef(null); const manualReferenceBatchRef = useRef(false); const referenceDebugSignatureRef = useRef(""); const [sideTab, setSideTab] = useState("document"); const [referencesOpen, setReferencesOpen] = useState(true); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState(null); const [pinnedReferenceId, setPinnedReferenceId] = useState(null); const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); const [settings] = useLocalSettings(); const queryClient = useQueryClient(); const isMobile = useIsMobile(); const stackArtifactPanel = responsiveArtifactLayout && isMobile; // 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持 // 原有行为(新产物自动打开 + in-flight 预览不过滤)。 const writingModeEnabled = writingMode ?? !!settings.context.writing_mode; const artifactDirectoryModeEnabled = settings.display.artifactDirectoryModeEnabled ?? false; const { data: citationDisplay } = useCitationDisplaySettings(); const referenceModeEntryEnabled = citationDisplay?.reference_mode_enabled ?? false; const ragModeEnabled = (referenceModeEntryEnabled && settings.display.ragModeEnabled) || isThreadRagModeLocked(threadId) || knowledgeReferencesEnabled || !!knowledgeReferences; // Frontend RAG modes write their own hidden source tool messages. They use // the existing reference panel, but must not request or write reference // batches through DeerFlow's backend APIs. const hasFrontendRag = useMemo( () => (thread.values.messages ?? []).some((message) => isSingleRoundRagMessage(message), ), [thread.values.messages], ); const referencesEnabled = ragModeEnabled || hasFrontendRag; const shouldFetchBackendReferenceBatches = referencesEnabled && !hasFrontendRag && !!threadId && threadId !== "new"; const { skills } = useSkills(); const canScanArtifactDirectory = (artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) && !disableArtifacts && !isMock && !!threadId && threadId !== "new"; const artifactListingQuery = useQuery({ queryKey: ["artifact-directory", threadId], queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), enabled: canScanArtifactDirectory, refetchInterval: thread.isLoading || pollForPreferredArtifact ? 1_500 : false, retry: false, }); const checkpointArtifacts = useMemo( () => (disableArtifacts ? [] : (thread.values.artifacts ?? [])), [disableArtifacts, thread.values.artifacts], ); const checkpointArtifactSignature = checkpointArtifacts.join("\n"); const availableArtifacts = useMemo( () => { if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) { return checkpointArtifacts; } const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map( (file) => file.path, ); if (requestedArtifact) discoveredArtifacts.push(requestedArtifact); return mergeArtifactPaths(checkpointArtifacts, discoveredArtifacts); }, [ artifactDirectoryModeEnabled, artifactListingQuery.data?.files, checkpointArtifacts, preferredArtifact, requestedArtifact, ], ); const availablePreferredArtifact = useMemo(() => { if (!preferredArtifact) return null; const preferredPath = artifactPathname(preferredArtifact); return ( [...availableArtifacts] .reverse() .find((artifact) => artifactPathname(artifact) === preferredPath) ?? null ); }, [availableArtifacts, preferredArtifact]); useEffect(() => { if (!preferredArtifact) return; onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact); }, [ availablePreferredArtifact, onPreferredArtifactAvailabilityChange, preferredArtifact, ]); useEffect(() => { if (!canScanArtifactDirectory || thread.isLoading) return; // Run once after streaming settles as well as when checkpoint artifacts // change, so files written without present_files are not missed by the // last polling tick. void artifactListingQuery.refetch(); }, [ artifactListingQuery.refetch, canScanArtifactDirectory, checkpointArtifactSignature, thread.isLoading, ]); const { artifacts, open: artifactsOpen, setOpen: setArtifactsOpen, setArtifacts, select: selectArtifact, deselect, selectedArtifact, selectedArtifactKey, openStreamedArtifact, setGeneratingArtifact, } = useArtifacts(); // Keep the lock even when the user closes the preview while writing. const selectedFileGenerating = Boolean( selectedArtifact && thread.isLoading && isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact), ); useLayoutEffect(() => { const owner = `chat:${threadId}`; setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null); return () => setGeneratingArtifact(owner, null); }, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const skillDisplayMap = useMemo( () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), [referencesEnabled, skills], ); const fallbackReferenceBatches = useMemo(() => { if (!referencesEnabled) { return []; } return buildTurnReferenceBatches( thread.values.messages ?? [], skillDisplayMap, thread.isLoading, ); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); useEffect(() => { if ( disableArtifacts || !requestedArtifact || !threadId || threadId === "new" ) { return; } const requestKey = `${threadId}:${artifactPathname(requestedArtifact)}`; if (openedRequestedArtifactRef.current === requestKey) return; openedRequestedArtifactRef.current = requestKey; selectArtifact(requestedArtifact); setArtifactsOpen(true); setSideTab("document"); }, [ disableArtifacts, requestedArtifact, selectArtifact, setArtifactsOpen, threadId, ]); useEffect(() => { const completedNow = preferredRunWasLoadingRef.current && !thread.isLoading; preferredRunWasLoadingRef.current = thread.isLoading; if (disableArtifacts || !preferredArtifact) { openedPreferredArtifactRef.current = null; return; } if (!availablePreferredArtifact) return; const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`; if (openedPreferredArtifactRef.current === preferredKey && !completedNow) { return; } openedPreferredArtifactRef.current = preferredKey; selectArtifact(availablePreferredArtifact, true); setArtifactsOpen(true); setSideTab("document"); // The same canonical report path may be overwritten by a later run while // its content query is still inside the five-minute cache window. Invalidate // the real selected path before the detail panel mounts/reopens so it never // shows the previous report after the assistant announces completion. void queryClient.invalidateQueries({ queryKey: ["artifact", availablePreferredArtifact, threadId], }); }, [ availablePreferredArtifact, disableArtifacts, preferredArtifact, queryClient, selectArtifact, setArtifactsOpen, thread.isLoading, threadId, ]); useEffect(() => { if (!shouldFetchBackendReferenceBatches) { setBackendReferenceBatches([]); return; } if (thread.isLoading) { return; } const controller = new AbortController(); logReferenceDebug("开始拉取后端参考文献", threadId, { messageCount: thread.values.messages?.length ?? 0, }); apiFetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`, { signal: controller.signal }, ) .then(async (response) => { if (!response.ok) { throw new Error(`Failed to load reference batches: ${response.status}`); } return (await response.json()) as { batches?: BackendReferenceBatch[] }; }) .then((data) => { const batches = data.batches ?? []; logReferenceDebug("后端参考文献返回", threadId, { batchCount: batches.length, sourceCounts: batches.map((batch) => batch.sources?.length ?? 0), batchIds: batches.map((batch) => batch.id), }); setBackendReferenceBatches(batches); }) .catch((error) => { if (!controller.signal.aborted) { console.warn("Failed to load backend reference batches", error); } }); return () => controller.abort(); }, [ shouldFetchBackendReferenceBatches, thread.isLoading, threadId, thread.values.messages, ]); const adaptedBackendReferenceBatches = useMemo( () => adaptBackendReferenceBatches( backendReferenceBatches, skillDisplayMap, ), [backendReferenceBatches, skillDisplayMap], ); const resolvedReferenceBatches = useMemo( () => { if ( shouldFetchBackendReferenceBatches && adaptedBackendReferenceBatches.length > 0 ) { return adaptedBackendReferenceBatches; } return fallbackReferenceBatches; }, [ adaptedBackendReferenceBatches, fallbackReferenceBatches, shouldFetchBackendReferenceBatches, ], ); const referenceBatches = useMemo(() => { if (!knowledgeReferences) return resolvedReferenceBatches; return resolvedReferenceBatches .map((batch) => ({ ...batch, sources: filterKnowledgeWikiSources( batch.sources, knowledgeReferences, ), })) .filter((batch) => batch.sources.length > 0); }, [knowledgeReferences, resolvedReferenceBatches]); useEffect(() => { if (!shouldFetchBackendReferenceBatches) { return; } const signature = JSON.stringify({ backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0), fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), final: referenceBatches.map((batch) => batch.sources?.length ?? 0), }); if (referenceDebugSignatureRef.current === signature) { return; } referenceDebugSignatureRef.current = signature; logReferenceDebug("前端参考文献来源选择", threadId, { using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底", backendBatchCount: backendReferenceBatches.length, backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0), fallbackBatchCount: fallbackReferenceBatches.length, fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), finalBatchCount: referenceBatches.length, finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0), backendBatches: debugReferenceBatches(backendReferenceBatches), adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches), fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), finalBatches: debugReferenceBatches(referenceBatches), }); }, [ adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referenceBatches, shouldFetchBackendReferenceBatches, threadId, ]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! : null; const activeReferenceBatch = referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ?? latestReferenceBatch; const referenceSources = activeReferenceBatch?.sources ?? []; const hasCitationReferences = referenceSources.length > 0; useEffect(() => { if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) { return; } logReferenceDebug("frontend-active-reference-batch-payload", threadId, { activeBatchId: activeReferenceBatch.id, assistantMessageId: activeReferenceBatch.assistant_message_id, sourceCount: activeReferenceBatch.sources.length, sources: activeReferenceBatch.sources.map(debugReferenceSource), }); }, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]); useEffect(() => { if (hasCitationReferences) { setReferencesOpen(true); } }, [hasCitationReferences]); useEffect(() => { if (!latestReferenceBatch) { setActiveReferenceBatchId(null); latestReferenceBatchIdRef.current = null; manualReferenceBatchRef.current = false; return; } const previousLatestId = latestReferenceBatchIdRef.current; latestReferenceBatchIdRef.current = latestReferenceBatch.id; setActiveReferenceBatchId((current) => current && referenceBatches.some((batch) => batch.id === current) && manualReferenceBatchRef.current && current !== previousLatestId ? current : latestReferenceBatch.id, ); }, [latestReferenceBatch?.id, referenceBatches]); useEffect(() => { const handleShowReference = (event: Event) => { const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail; if (detail?.batchId) { manualReferenceBatchRef.current = true; setActiveReferenceBatchId(detail.batchId); } setPinnedReferenceId(null); window.setTimeout(() => { setPinnedReferenceId(detail?.referenceId ?? null); }, 0); setReferencesOpen(true); setSideTab("references"); }; window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference); return () => { window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference); }; }, []); useEffect(() => { const threadChanged = threadIdRef.current !== threadId; if (threadChanged) { threadIdRef.current = threadId; artifactsCountRef.current = 0; deselect(); setReferencesOpen(false); setActiveReferenceBatchId(null); setPinnedReferenceId(null); setBackendReferenceBatches([]); latestReferenceBatchIdRef.current = null; manualReferenceBatchRef.current = false; referenceDebugSignatureRef.current = ""; } // Update artifacts from the current thread const nextArtifacts = availableArtifacts; const hadArtifacts = artifactsCountRef.current > 0; const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current; artifactsCountRef.current = nextArtifacts.length; const artifactsChanged = artifacts.length !== nextArtifacts.length || artifacts.some((artifact, index) => artifact !== nextArtifacts[index]); if (artifactsChanged) { setArtifacts(nextArtifacts); } if ( !disableArtifacts && nextArtifacts.length > 0 && ((artifactDirectoryModeEnabled && threadChanged) || (hasNewArtifact || !hadArtifacts)) ) { // The preferred deliverable wins over the generic "newest artifact" // heuristic. Both effects can run in the same commit with a stale null // selectedArtifact; choosing the preferred path here prevents a sibling // file such as research-plan.json from overwriting report.md. const automaticTarget = availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!; const isGeneratedHtml = languageOfStreamingArtifact(automaticTarget) === "html" && isAutoPreviewableArtifactPath(automaticTarget); if ( shouldAutoOpenWritingArtifact( automaticTarget, thread.messages, writingModeEnabled, ) && (isGeneratedHtml || !selectedArtifact) ) { if (isGeneratedHtml) { // HTML is the requested end product, so a later webpage should // replace a previously selected document automatically. Preserve a // matching in-flight write key so a user-closed live preview stays // closed after the tool call completes; otherwise a completed // tool-call id makes a later write to the same path a new preview. const revision = latestCompletedArtifactMutationKey( { messages: thread.messages ?? [] }, automaticTarget, ); const previewKey = selectedArtifact?.startsWith("write-file:") && artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) && selectedArtifactKey ? selectedArtifactKey : revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`; openStreamedArtifact( automaticTarget, previewKey, ); } else { selectArtifact(automaticTarget, true); setArtifactsOpen(true); setSideTab("document"); } } } // 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 ( !disableArtifacts && env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && autoSelectFirstArtifact ) { if (thread?.values?.artifacts?.length > 0) { setAutoSelectFirstArtifact(false); const firstPreviewableArtifact = thread.values.artifacts.find( (artifact) => !isWordDocumentFile(artifact), ); if (firstPreviewableArtifact) { selectArtifact(firstPreviewableArtifact, true); } } } }, [ threadId, artifactDirectoryModeEnabled, autoSelectFirstArtifact, deselect, selectArtifact, openStreamedArtifact, selectedArtifact, selectedArtifactKey, artifacts, setArtifacts, setArtifactsOpen, availableArtifacts, availablePreferredArtifact, disableArtifacts, thread.messages, writingModeEnabled, ]); // Live sandbox: open when an in-flight write_file has a usable output path. // Pathless content stays inline in the CoT, including content-first streams. // Agentfx (and other agents) often emit write_file + present_files/bash in // the same turn; the collapsed earlier write would otherwise never auto-open. const streamingWritePreview = useMemo( () => disableArtifacts || !thread.isLoading ? null : latestInFlightWriteFilePreview(thread.messages ?? [], writingModeEnabled), [disableArtifacts, thread.isLoading, thread.messages, writingModeEnabled], ); useEffect(() => { if (!streamingWritePreview) return; openStreamedArtifact( streamingWritePreview.artifactPath, streamingWritePreview.previewKey, ); setSideTab("document"); }, [openStreamedArtifact, streamingWritePreview]); // page_report_template(prepare) creates the output HTML before content // editing starts. Open that real artifact immediately; this also covers // models that use str_replace or whose write_file arguments arrive at once. const preparedHtmlPreview = useMemo( () => disableArtifacts || !thread.isLoading ? null : latestPreparedHtmlArtifact(thread.messages ?? []), [disableArtifacts, thread.isLoading, thread.messages], ); useEffect(() => { if (!preparedHtmlPreview) return; openStreamedArtifact( preparedHtmlPreview.artifactPath, preparedHtmlPreview.previewKey, ); setSideTab("document"); }, [openStreamedArtifact, preparedHtmlPreview]); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled); useEffect(() => { const wasEnabled = directoryModeWasEnabledRef.current; directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled; if ( wasEnabled || !artifactDirectoryModeEnabled || availableArtifacts.length === 0 || isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!) ) { return; } if (!selectedArtifact) { selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true); } setArtifactsOpen(true); setSideTab("document"); }, [ artifactDirectoryModeEnabled, availableArtifacts, selectArtifact, selectedArtifact, setArtifactsOpen, ]); const artifactPanelOpen = useMemo(() => { if (disableArtifacts) { return false; } if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { return artifactsOpen && artifacts?.length > 0; } return artifactsOpen; }, [artifactsOpen, artifacts, disableArtifacts]); const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen); const showTabs = hasCitationReferences && (!!selectedArtifact || (artifactDirectoryModeEnabled && artifacts.length > 0)); useEffect(() => { if (selectedArtifact) { setSideTab("document"); } else if (hasCitationReferences) { setSideTab("references"); } }, [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( artifactDirectoryModeEnabled && artifactPanelOpen ? DIRECTORY_OPEN_MODE : OPEN_MODE, ); } else { layoutRef.current.setLayout(CLOSE_MODE); } } }, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]); const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
{ selectArtifact(artifact); setArtifactsOpen(true); }} onRefresh={ canScanArtifactDirectory ? () => void artifactListingQuery.refetch() : undefined } refreshing={artifactListingQuery.isFetching} />
{selectedArtifact ? ( ) : ( <>
} title="请选择文件" description="从左侧目录中选择一个文件进行查看" /> )}
) : selectedArtifact ? ( ) : (
{artifacts.length === 0 ? ( } title="No artifact selected" description="Select an artifact to view its details" /> ) : (

Artifacts

)}
); if (disableArtifacts) { return ( {children} ); } return ( {children}
{showTabs ? (
文档 {knowledgeReferences ? "Wiki" : "参考文献"}
{artifactDocumentPanel} {knowledgeReferences ? ( ) : ( )}
) : artifactPanelOpen ? ( artifactDocumentPanel ) : hasCitationReferences ? (
{knowledgeReferences ? ( ) : ( )}
) : null}
); }; function ReferencePanel({ highlightedReferenceId, sources, }: { highlightedReferenceId?: string | null; sources: Parameters[0]["sources"]; }) { const orderedSources = useMemo(() => { if (!highlightedReferenceId) { return sources; } const index = Number(highlightedReferenceId); if (!Number.isFinite(index)) { return sources; } const selected = sources.find((source) => source.index === index); if (!selected) { return sources; } return [selected, ...sources.filter((source) => source.index !== index)]; }, [highlightedReferenceId, sources]); return (

参考文献

{sources.length} 条

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

); } function debugReferenceBatches(batches: BackendReferenceBatch[]) { return batches.map((batch) => ({ id: batch.id, assistantMessageId: batch.assistant_message_id, sourceCount: batch.sources?.length ?? 0, sources: (batch.sources ?? []).map(debugReferenceSource), })); } function debugReferenceSource(source: BackendReferenceSource) { const raw = source.raw && typeof source.raw === "object" && !Array.isArray(source.raw) ? (source.raw as Record) : {}; return { index: source.index, id: source.id, skillName: source.skillName, mode: source.mode, title: source.title, source: source.source, author: source.author, time: source.time, url: source.url, snippet: source.snippet, content: source.content, rawIdCandidates: { recUuid: raw.recUuid, rec_uuid: raw.rec_uuid, uuid: raw.uuid, docId: raw.docId, doc_id: raw.doc_id, id: raw.id, url: raw.url, }, raw: source.raw, }; } function adaptBackendReferenceBatches( backendBatches: BackendReferenceBatch[], skillDisplayMap: ReturnType, ): BackendReferenceBatch[] { if (backendBatches.length === 0) { return backendBatches; } return backendBatches.map((backendBatch) => { return { ...backendBatch, sources: backendBatch.sources.map((backendSource) => { const configuredSource = adaptBackendSourceWithSkillDisplay( backendSource, skillDisplayMap, ); if (!configuredSource) { return backendSource; } return { ...backendSource, ...configuredSource, index: backendSource.index, mode: backendSource.mode !== "citation" ? backendSource.mode : configuredSource.mode || backendSource.mode, raw: backendSource.raw ?? configuredSource.raw, }; }), }; }); } function adaptBackendSourceWithSkillDisplay( source: BackendReferenceSource, skillDisplayMap: ReturnType, ): BackendReferenceSource | null { const raw = source.raw; if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return null; } const rawItem = raw as Record; const match = findBackendSourceDisplay(source, skillDisplayMap); if (!match) { return null; } const display = findDisplayConfigForBackendSource(source, match.display); const normalized = normalizeDisplaySources( buildPayloadAtPath(display.resultPath || "results", [rawItem]), display, )[0]; if (!normalized) { return null; } return { ...source, ...normalized, skillName: match.skillName, mode: display.mode, tableColumns: display.tableColumns, index: source.index, raw: source.raw, }; } function findDisplayConfigForBackendSource( source: BackendReferenceSource, display: SkillDisplayConfig, ) { const resultSetKey = source.resultSetKey; if (resultSetKey) { const resultSet = display.resultSets?.find((item) => item.key === resultSetKey); if (resultSet) { return displayConfigFromResultSet(display, resultSet); } } return { ...display, resultSets: [] }; } function findBackendSourceDisplay( source: BackendReferenceSource, skillDisplayMap: ReturnType, ) { const direct = skillDisplayMap.get(source.skillName); if (direct) { return { skillName: source.skillName, display: direct }; } const normalizedSource = normalizeSkillName(source.skillName); for (const [skillName, display] of skillDisplayMap) { const normalizedSkill = normalizeSkillName(skillName); if ( normalizedSource === normalizedSkill || normalizedSource.endsWith(normalizedSkill) || normalizedSource.includes(normalizedSkill) ) { return { skillName, display }; } } return null; } function normalizeSkillName(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); } function buildPayloadAtPath(path: string, items: Record[]) { const parts = path.split(".").map((part) => part.trim()).filter(Boolean); if (parts.length === 0) { return items; } const root: Record = {}; let cursor = root; for (let index = 0; index < parts.length; index++) { const part = parts[index]!; if (index === parts.length - 1) { cursor[part] = items; } else { const next: Record = {}; cursor[part] = next; cursor = next; } } return root; } 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 buildTurnReferenceBatches( messages: Message[], skillDisplayMap: ReturnType, isLoading: boolean, ): BackendReferenceBatch[] { const batches: BackendReferenceBatch[] = []; for (let index = 0; index < messages.length; index++) { const human = messages[index]; if (human?.type !== "human" || !human.id) { continue; } const nextHumanIndex = findNextHumanIndex(messages, index + 1); const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex; const assistant = findLastAssistantAnswer(messages, index + 1, endIndex) ?? findFrontendRagAssistant(messages, index + 1, endIndex); if (!assistant?.id) { continue; } const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, { afterMessageId: human.id, beforeMessageId: nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, includeBeforeMessage: false, }).filter((source) => source.mode !== "none"); // GeneralQA's single-RAG stream sends `docs` before the answer. Make // those cards available immediately, while keeping normal streaming turns // on their existing "wait for an answer" behavior. const isFrontendRagTurn = isSingleRoundRagMessage(assistant); if (nextHumanIndex === -1 && isLoading && !isFrontendRagTurn) { continue; } if (sources.length > 0) { batches.push({ id: assistant.id, assistant_message_id: assistant.id, sources, }); } } return batches; } function findNextHumanIndex(messages: Message[], start: number): number { for (let index = start; index < messages.length; index++) { if (messages[index]?.type === "human") { return index; } } return -1; } function findLastAssistantAnswer( messages: Message[], start: number, end: number, ): Message | null { for (let index = end - 1; index >= start; index--) { const message = messages[index]; if ( message?.type === "ai" && message.id && hasAssistantAnswerContent(message) ) { return message; } } return null; } function findFrontendRagAssistant( messages: Message[], start: number, end: number, ): Message | null { for (let index = end - 1; index >= start; index -= 1) { const message = messages[index]; if ( message?.type === "ai" && isSingleRoundRagMessage(message) ) { return message; } } return null; } function hasAssistantAnswerContent(message: Message): boolean { const content = message.content; if (typeof content === "string") { return content.trim().length > 0; } if (!Array.isArray(content)) { return false; } return content.some((part) => { if (!part || typeof part !== "object") { return false; } const block = part as Record; return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0; }); } export { ChatBox };