This commit is contained in:
fengxiangdebaozi committed 2026-08-19 21:24:58 +08:00
commit 8e77e2da1e
3 files changed
+140 -55

No files matched your search

@@ -15,7 +15,7 @@ import {
SquareTerminalIcon, SquareTerminalIcon,
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, type AnchorHTMLAttributes } from "react";
import { import {
ChainOfThought, ChainOfThought,
@@ -58,6 +58,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { import {
applyInlineCitations,
buildSkillDisplayMap, buildSkillDisplayMap,
findDisplayConfigForTool, findDisplayConfigForTool,
isDisplaySamplePayload, isDisplaySamplePayload,
@@ -71,6 +72,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { CitationLink } from "../citations/citation-link";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { import {
@@ -79,15 +81,18 @@ import {
} from "../skill-display-sources"; } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
import { SingleRoundRagCitationDialog } from "./message-list-item";
export function MessageGroup({ export function MessageGroup({
className, className,
messages, messages,
isLoading = false, isLoading = false,
displaySources = [],
}: { }: {
className?: string; className?: string;
messages: Message[]; messages: Message[];
isLoading?: boolean; isLoading?: boolean;
displaySources?: RuntimeDisplaySource[];
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [showAbove, setShowAbove] = useState( const [showAbove, setShowAbove] = useState(
@@ -124,11 +129,29 @@ export function MessageGroup({
} }
}, [lastToolCallStep, steps]); }, [lastToolCallStep, steps]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const reasoningId =
lastReasoningStep?.id ??
messages.find((message) => message.type === "ai")?.id;
return ( return (
<ChainOfThought <>
className={cn("w-full gap-2 rounded-lg border p-0.5", className)} <ChainOfThought
open={true} className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
> data-reference-batch-id={reasoningId}
open={true}
>
{aboveLastToolCallSteps.length > 0 && ( {aboveLastToolCallSteps.length > 0 && (
<Button <Button
key="above" key="above"
@@ -163,10 +186,12 @@ export function MessageGroup({
<ChainOfThoughtStep <ChainOfThoughtStep
key={step.id} key={step.id}
label={ label={
<MarkdownContent <StepReasoningMarkdown
content={step.reasoning ?? ""} content={step.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -223,10 +248,12 @@ export function MessageGroup({
<ChainOfThoughtStep <ChainOfThoughtStep
key={lastReasoningStep.id} key={lastReasoningStep.id}
label={ label={
<MarkdownContent <StepReasoningMarkdown
content={lastReasoningStep.reasoning ?? ""} content={lastReasoningStep.reasoning ?? ""}
displaySources={displaySources}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
onCitationClick={handleCitationClick}
/> />
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -234,7 +261,60 @@ export function MessageGroup({
)} )}
</> </>
)} )}
</ChainOfThought> </ChainOfThought>
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
</>
);
}
function StepReasoningMarkdown({
content,
displaySources,
isLoading,
rehypePlugins,
onCitationClick,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onCitationClick: (referenceId: string) => void;
}) {
const markdown = useMemo(
() =>
applyInlineCitations(content, displaySources, { appendSummary: false }),
[content, displaySources],
);
const components = useMemo(() => {
if (displaySources.length === 0) return undefined;
return {
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={onCitationClick}
/>
);
}
return <a href={href} {...props} />;
},
};
}, [displaySources.length, onCitationClick]);
return (
<MarkdownContent
content={markdown}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
); );
} }
@@ -695,13 +695,6 @@ function MessageContent_({
const withCitations = applyInlineCitations(rawContent ?? '', displaySources); const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
return stripIncompleteCitationMarker(withCitations); return stripIncompleteCitationMarker(withCitations);
}, [rawContent, isHuman, displaySources]); }, [rawContent, isHuman, displaySources]);
const reasoningToDisplay = useMemo(() => {
if (!reasoningContent) return '';
const withCitations = applyInlineCitations(reasoningContent, displaySources, {
appendSummary: false,
});
return stripIncompleteCitationMarker(withCitations);
}, [reasoningContent, displaySources]);
const markdownRehypePlugins = useMemo( const markdownRehypePlugins = useMemo(
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]], () => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
[rehypePlugins], [rehypePlugins],
@@ -735,6 +728,23 @@ function MessageContent_({
); );
} }
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
</AIElementMessageContent>
);
}
if (isHuman) { if (isHuman) {
// Only the user's own question carries a delete affordance, and only once // Only the user's own question carries a delete affordance, and only once
// it exists server-side (real, non-optimistic id). Deleting it cascades to // it exists server-side (real, non-optimistic id). Deleting it cascades to
@@ -770,24 +780,6 @@ function MessageContent_({
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
<div data-reference-batch-id={message.id} className="contents"> <div data-reference-batch-id={message.id} className="contents">
{reasoningToDisplay && !multiRoundTrace && (
<Reasoning isStreaming={isLoading && !contentToDisplay}>
<ReasoningTrigger />
<ReasoningContent>
{isSingleRoundRag || displaySources.length > 0 ? (
<MarkdownContent
content={reasoningToDisplay}
isLoading={isLoading}
rehypePlugins={markdownRehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
) : (
reasoningToDisplay
)}
</ReasoningContent>
</Reasoning>
)}
{multiRoundTrace ? ( {multiRoundTrace ? (
<MultiRoundRagContent <MultiRoundRagContent
trace={multiRoundTrace} trace={multiRoundTrace}
@@ -797,7 +789,7 @@ function MessageContent_({
rehypePlugins={markdownRehypePlugins} rehypePlugins={markdownRehypePlugins}
components={components} components={components}
/> />
) : contentToDisplay ? ( ) : (
<AssistantDisplayContent <AssistantDisplayContent
content={contentToDisplay} content={contentToDisplay}
displaySources={displaySources} displaySources={displaySources}
@@ -806,7 +798,7 @@ function MessageContent_({
rehypePlugins={markdownRehypePlugins} rehypePlugins={markdownRehypePlugins}
components={components} components={components}
/> />
) : null} )}
</div> </div>
{(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && ( {(isSingleRoundRag || isMultiRoundRag) && displaySources.length > 0 && (
<FrontendRagSourcesPanel <FrontendRagSourcesPanel
@@ -905,7 +897,7 @@ function FrontendRagSourcesPanel({
); );
} }
function SingleRoundRagCitationDialog({ export function SingleRoundRagCitationDialog({
source, source,
onOpenChange, onOpenChange,
}: { }: {
@@ -322,6 +322,32 @@ export function MessageList({
return map; return map;
}, [thread.messages]); }, [thread.messages]);
const resolveDisplaySources = useCallback(
(msg: Message | undefined): RuntimeDisplaySource[] => {
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
const batchSources =
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
?.sources ?? [];
const frontendRagSources = msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
if (batchSources.length > 0) return batchSources;
if (frontendRagSources.length > 0) return frontendRagSources;
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
});
},
[
frontendRagSourcesByAiId,
messages,
referenceBatches,
referencesEnabled,
skillDisplayMap,
],
);
// Track which clarification messages are still awaiting an answer. // Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message // Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet). // (no user reply has been sent for them yet).
@@ -457,26 +483,8 @@ export function MessageList({
referencesEnabled && referencesEnabled &&
group.type === "assistant" && group.type === "assistant" &&
msg.type === "ai"; msg.type === "ai";
const batchSources = isAssistantMessage
? referenceBatches.find(
(batch) => batch.assistant_message_id === msg.id,
)?.sources ?? []
: [];
// 单轮/多轮 RAG 的参考资料来自隐藏的 tool 消息,优先于通用扫描。
const frontendRagSources =
isAssistantMessage && msg.id
? frontendRagSourcesByAiId.get(msg.id) ?? []
: [];
const displaySources = isAssistantMessage const displaySources = isAssistantMessage
? batchSources.length > 0 ? resolveDisplaySources(msg)
? batchSources
: frontendRagSources.length > 0
? frontendRagSources
: collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: []; : [];
return ( return (
<MessageListItem <MessageListItem
@@ -683,6 +691,7 @@ export function MessageList({
key={"thinking-group-" + message.id} key={"thinking-group-" + message.id}
messages={[message]} messages={[message]}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(message)}
/>, />,
); );
} }
@@ -726,11 +735,15 @@ export function MessageList({
message.type === "ai" && message.type === "ai" &&
(hasToolCalls(message) ? true : !hasContent(message)), (hasToolCalls(message) ? true : !hasContent(message)),
); );
const processingAi = [...group.messages]
.reverse()
.find((message) => message.type === "ai");
return ( return (
<div key={"group-" + group.id} className="w-full"> <div key={"group-" + group.id} className="w-full">
<MessageGroup <MessageGroup
messages={group.messages} messages={group.messages}
isLoading={thread.isLoading} isLoading={thread.isLoading}
displaySources={resolveDisplaySources(processingAi)}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}