- 智能体支持从广场收藏到「我的」(新增 agent_favorites 表与收藏/取消收藏接口) - 定时任务新增「必须生成Markdown文件」开关:注入提示词并校验生成的 .md 文件且正文超过100字,否则记为失败(存于 execution_context,无需迁移) - 聊天问答展示时间(年月日 时分):问题在右下角、回答在左下角 - 常用提示词改为与账号绑定(新增 user_custom_prompts 表与 /api/user-prompts 接口),仅在首页输入框上方以居中标签 + 轮播展示,齿轮置于最右侧管理 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
770 lines
25 KiB
TypeScript
770 lines
25 KiB
TypeScript
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 { apiFetch } from "@/core/api";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
|
import { useSkills } from "@/core/skills/hooks";
|
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
|
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";
|
|
|
|
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 }> = ({
|
|
children,
|
|
threadId,
|
|
}) => {
|
|
const { thread } = useThread();
|
|
const pathname = usePathname();
|
|
const threadIdRef = useRef(threadId);
|
|
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 ragModeEnabled =
|
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
|
const { skills } = useSkills();
|
|
|
|
const {
|
|
artifacts,
|
|
open: artifactsOpen,
|
|
setOpen: setArtifactsOpen,
|
|
setArtifacts,
|
|
select: selectArtifact,
|
|
deselect,
|
|
selectedArtifact,
|
|
} = useArtifacts();
|
|
|
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
|
const skillDisplayMap = useMemo(
|
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
|
[ragModeEnabled, skills],
|
|
);
|
|
const fallbackReferenceBatches = useMemo(() => {
|
|
if (!ragModeEnabled) {
|
|
return [];
|
|
}
|
|
return buildTurnReferenceBatches(
|
|
thread.values.messages ?? [],
|
|
skillDisplayMap,
|
|
thread.isLoading,
|
|
);
|
|
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
|
useEffect(() => {
|
|
if (!ragModeEnabled) {
|
|
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();
|
|
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
|
const adaptedBackendReferenceBatches = useMemo(
|
|
() =>
|
|
adaptBackendReferenceBatches(
|
|
backendReferenceBatches,
|
|
skillDisplayMap,
|
|
),
|
|
[backendReferenceBatches, skillDisplayMap],
|
|
);
|
|
const referenceBatches = useMemo(
|
|
() => {
|
|
if (adaptedBackendReferenceBatches.length > 0) {
|
|
return adaptedBackendReferenceBatches;
|
|
}
|
|
return fallbackReferenceBatches;
|
|
},
|
|
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
|
);
|
|
useEffect(() => {
|
|
if (!ragModeEnabled) {
|
|
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, ragModeEnabled, referenceBatches, 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 (!ragModeEnabled || !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, ragModeEnabled, 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(() => {
|
|
if (threadIdRef.current !== threadId) {
|
|
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 = thread.values.artifacts ?? [];
|
|
const hadArtifacts = artifactsCountRef.current > 0;
|
|
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
|
artifactsCountRef.current = nextArtifacts.length;
|
|
setArtifacts(nextArtifacts);
|
|
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
|
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
|
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 (
|
|
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) {
|
|
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(OPEN_MODE);
|
|
} else {
|
|
layoutRef.current.setLayout(CLOSE_MODE);
|
|
}
|
|
}
|
|
}, [sidePanelOpen]);
|
|
|
|
return (
|
|
<ResizablePanelGroup
|
|
id={`${resizableIdBase}-panels`}
|
|
orientation="horizontal"
|
|
defaultLayout={{ chat: 100, artifacts: 0 }}
|
|
groupRef={layoutRef}
|
|
>
|
|
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
|
{children}
|
|
</ResizablePanel>
|
|
<ResizableHandle
|
|
id={`${resizableIdBase}-separator`}
|
|
className={cn(
|
|
"opacity-33 hover:opacity-100",
|
|
!sidePanelOpen && "pointer-events-none opacity-0",
|
|
)}
|
|
/>
|
|
<ResizablePanel
|
|
className={cn(
|
|
"bg-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">参考文献</TabsTrigger>
|
|
</TabsList>
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setReferencesOpen(false);
|
|
setArtifactsOpen(false);
|
|
}}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
|
<ArtifactFileDetail
|
|
className="size-full"
|
|
filepath={selectedArtifact}
|
|
threadId={threadId}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
|
<ReferencePanel
|
|
highlightedReferenceId={pinnedReferenceId}
|
|
sources={referenceSources}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
) : selectedArtifact ? (
|
|
<ArtifactFileDetail
|
|
className="size-full"
|
|
filepath={selectedArtifact}
|
|
threadId={threadId}
|
|
/>
|
|
) : hasCitationReferences ? (
|
|
<div className="relative size-full overflow-hidden">
|
|
<div className="absolute top-1 right-1 z-30">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setReferencesOpen(false);
|
|
setArtifactsOpen(false);
|
|
}}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
<ReferencePanel
|
|
highlightedReferenceId={pinnedReferenceId}
|
|
sources={referenceSources}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="relative flex size-full justify-center">
|
|
<div className="absolute top-1 right-1 z-30">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setArtifactsOpen(false);
|
|
}}
|
|
>
|
|
<XIcon />
|
|
</Button>
|
|
</div>
|
|
{thread.values.artifacts?.length === 0 ? (
|
|
<ConversationEmptyState
|
|
icon={<FilesIcon />}
|
|
title="No artifact selected"
|
|
description="Select an artifact to view its details"
|
|
/>
|
|
) : (
|
|
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
|
<header className="shrink-0">
|
|
<h2 className="text-lg font-medium">Artifacts</h2>
|
|
</header>
|
|
<main className="min-h-0 grow">
|
|
<ArtifactFileList
|
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
|
files={thread.values.artifacts ?? []}
|
|
threadId={threadId}
|
|
/>
|
|
</main>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
);
|
|
};
|
|
|
|
function ReferencePanel({
|
|
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 normalized = normalizeDisplaySources(
|
|
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
|
match.display,
|
|
)[0];
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
return {
|
|
...source,
|
|
...normalized,
|
|
skillName: match.skillName,
|
|
mode: match.display.mode,
|
|
tableColumns: match.display.tableColumns,
|
|
index: source.index,
|
|
raw: source.raw,
|
|
};
|
|
}
|
|
|
|
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);
|
|
if (nextHumanIndex === -1 && isLoading) {
|
|
continue;
|
|
}
|
|
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
|
|
const assistant = findLastAssistantAnswer(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");
|
|
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 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 };
|