Files
deerflow-code/frontend-web/src/components/workspace/chats/chat-box.tsx
T

1284 lines
43 KiB
TypeScript

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<typeof SkillDisplaySources>[0]["sources"][number];
type BackendReferenceBatch = {
id: string;
assistant_message_id?: string;
sources: BackendReferenceSource[];
};
function logReferenceDebug(
event: string,
threadId: string,
payload: Record<string, unknown>,
) {
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<string | null>(null);
const openedPreferredArtifactRef = useRef<string | null>(null);
const preferredRunWasLoadingRef = useRef(false);
const artifactsCountRef = useRef(0);
const layoutRef = useRef<GroupImperativeHandle>(null);
const latestReferenceBatchIdRef = useRef<string | null>(null);
const manualReferenceBatchRef = useRef(false);
const referenceDebugSignatureRef = useRef<string>("");
const [sideTab, setSideTab] = useState("document");
const [referencesOpen, setReferencesOpen] = useState(true);
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
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 ? (
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
<ArtifactDirectory
artifacts={artifacts}
selectedArtifact={selectedArtifact}
onSelect={(artifact) => {
selectArtifact(artifact);
setArtifactsOpen(true);
}}
onRefresh={
canScanArtifactDirectory
? () => void artifactListingQuery.refetch()
: undefined
}
refreshing={artifactListingQuery.isFetching}
/>
<div className="relative min-w-0 flex-1 overflow-hidden">
{selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<>
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => setArtifactsOpen(false)}
>
<XIcon />
</Button>
</div>
<ConversationEmptyState
icon={<FilesIcon />}
title="请选择文件"
description="从左侧目录中选择一个文件进行查看"
/>
</>
)}
</div>
</div>
) : selectedArtifact ? (
<ArtifactFileDetail
key={selectedArtifactKey ?? selectedArtifact}
className="size-full"
filepath={selectedArtifact}
threadId={threadId}
allowVirtualMarkdownEdit
/>
) : (
<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>
{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={artifacts}
threadId={threadId}
/>
</main>
</div>
)}
</div>
);
if (disableArtifacts) {
return (
<ReferenceBatchesContext.Provider value={referenceBatches}>
{children}
</ReferenceBatchesContext.Provider>
);
}
return (
<ReferenceBatchesContext.Provider value={referenceBatches}>
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
orientation={stackArtifactPanel ? "vertical" : "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",
stackArtifactPanel &&
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
!sidePanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"bg-background transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-background 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">
{knowledgeReferences ? "Wiki" : "参考文献"}
</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">
{artifactDocumentPanel}
</TabsContent>
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</TabsContent>
</Tabs>
) : artifactPanelOpen ? (
artifactDocumentPanel
) : 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>
{knowledgeReferences ? (
<KnowledgeWikiPanel
highlightedReferenceId={pinnedReferenceId}
selection={knowledgeReferences}
sources={referenceSources}
/>
) : (
<ReferencePanel
highlightedReferenceId={pinnedReferenceId}
sources={referenceSources}
/>
)}
</div>
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ReferenceBatchesContext.Provider>
);
};
function ReferencePanel({
highlightedReferenceId,
sources,
}: {
highlightedReferenceId?: string | null;
sources: Parameters<typeof SkillDisplaySources>[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 (
<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
highlightedReferenceId={highlightedReferenceId}
sources={orderedSources}
/>
</main>
</div>
);
}
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<string, unknown>)
: {};
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<typeof buildSkillDisplayMap>,
): 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<typeof buildSkillDisplayMap>,
): BackendReferenceSource | null {
const raw = source.raw;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return null;
}
const rawItem = raw as Record<string, unknown>;
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<typeof buildSkillDisplayMap>,
) {
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<string, unknown>[]) {
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return items;
}
const root: Record<string, unknown> = {};
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<string, unknown> = {};
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<typeof buildSkillDisplayMap>,
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<string, unknown>;
return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
});
}
export { ChatBox };