1056 lines
35 KiB
TypeScript
1056 lines
35 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
||
import {
|
||
BookOpenTextIcon,
|
||
CheckCircle2Icon,
|
||
ChevronDownIcon,
|
||
ChevronsDownIcon,
|
||
ChevronUp,
|
||
FolderOpenIcon,
|
||
GlobeIcon,
|
||
LightbulbIcon,
|
||
ListTodoIcon,
|
||
Loader2Icon,
|
||
MessageCircleQuestionMarkIcon,
|
||
NotebookPenIcon,
|
||
SearchIcon,
|
||
SquareTerminalIcon,
|
||
WrenchIcon,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
|
||
|
||
import {
|
||
ChainOfThought,
|
||
ChainOfThoughtContent,
|
||
ChainOfThoughtSearchResult,
|
||
ChainOfThoughtSearchResults,
|
||
ChainOfThoughtStep,
|
||
} from "@/components/ai-elements/chain-of-thought";
|
||
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetDescription,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
} from "@/components/ui/sheet";
|
||
import {
|
||
buildWriteFileVirtualUrl,
|
||
hasCompleteArtifactExtension,
|
||
isAutoPreviewableArtifactPath,
|
||
languageOfStreamingArtifact,
|
||
normalizeStreamingArtifactPath,
|
||
provisionalWriteFilePath,
|
||
} from "@/core/artifacts/streaming-write-preview";
|
||
import { useI18n } from "@/core/i18n/hooks";
|
||
import {
|
||
normalizeSearchResults,
|
||
resolveSearchQuery,
|
||
type SearchResultItem,
|
||
} from "@/core/messages/search-results";
|
||
import {
|
||
extractReasoningContentFromMessage,
|
||
findToolCallResult,
|
||
} from "@/core/messages/utils";
|
||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||
import { useLocalSettings } from "@/core/settings";
|
||
import { useSkills } from "@/core/skills/hooks";
|
||
import {
|
||
applyInlineCitations,
|
||
buildSkillDisplayMap,
|
||
findDisplayConfigForTool,
|
||
isDisplaySamplePayload,
|
||
normalizeDefaultDisplaySources,
|
||
shouldUseDefaultCitationMode,
|
||
type RuntimeDisplaySource,
|
||
} from "@/core/skill-display/runtime";
|
||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||
import { isInterceptedWritingReportWrite } from "@/core/threads/writing-mode";
|
||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||
import { env } from "@/env";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { useArtifactsOptional } from "../artifacts";
|
||
import { CitationLink } from "../citations/citation-link";
|
||
import { FlipDisplay } from "../flip-display";
|
||
import { Tooltip } from "../tooltip";
|
||
import { SourceCard } from "../skill-display-sources";
|
||
|
||
import { MarkdownContent } from "./markdown-content";
|
||
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
||
|
||
export function MessageGroup({
|
||
className,
|
||
messages,
|
||
isLoading = false,
|
||
displaySources = [],
|
||
ragModeEnabled = false,
|
||
}: {
|
||
className?: string;
|
||
messages: Message[];
|
||
isLoading?: boolean;
|
||
displaySources?: RuntimeDisplaySource[];
|
||
ragModeEnabled?: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const [showAbove, setShowAbove] = useState(
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||
);
|
||
const [showLastThinking, setShowLastThinking] = useState(
|
||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||
);
|
||
const [settings] = useLocalSettings();
|
||
// 写作模式的「拦截 outputs/*.md 手写」只在写作模式生效;普通模式的
|
||
// write_file / read_file 步骤条必须照旧显示。
|
||
const writingModeEnabled = !!settings.context.writing_mode;
|
||
const steps = useMemo(
|
||
() => convertToSteps(messages, writingModeEnabled),
|
||
[messages, writingModeEnabled],
|
||
);
|
||
const { skills } = useSkills();
|
||
const skillDisplayMap = useMemo(
|
||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||
[ragModeEnabled, skills],
|
||
);
|
||
const lastToolCallStep = useMemo(() => {
|
||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||
return filteredSteps[filteredSteps.length - 1];
|
||
}, [steps]);
|
||
const aboveLastToolCallSteps = useMemo(() => {
|
||
if (lastToolCallStep) {
|
||
const index = steps.indexOf(lastToolCallStep);
|
||
return steps.slice(0, index);
|
||
}
|
||
return [];
|
||
}, [lastToolCallStep, steps]);
|
||
const lastReasoningStep = useMemo(() => {
|
||
if (lastToolCallStep) {
|
||
const index = steps.indexOf(lastToolCallStep);
|
||
return steps.slice(index + 1).find((step) => step.type === "reasoning");
|
||
} else {
|
||
const filteredSteps = steps.filter((step) => step.type === "reasoning");
|
||
return filteredSteps[filteredSteps.length - 1];
|
||
}
|
||
}, [lastToolCallStep, steps]);
|
||
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 (
|
||
<>
|
||
<ChainOfThought
|
||
className={cn("w-full gap-2 rounded-lg border p-0.5", className)}
|
||
data-reference-batch-id={reasoningId}
|
||
open={true}
|
||
>
|
||
{aboveLastToolCallSteps.length > 0 && (
|
||
<Button
|
||
key="above"
|
||
className="w-full items-start justify-start text-left"
|
||
variant="ghost"
|
||
onClick={() => setShowAbove(!showAbove)}
|
||
>
|
||
<ChainOfThoughtStep
|
||
label={
|
||
<span className="opacity-60">
|
||
{showAbove
|
||
? t.toolCalls.lessSteps
|
||
: t.toolCalls.moreSteps(aboveLastToolCallSteps.length)}
|
||
</span>
|
||
}
|
||
icon={
|
||
<ChevronUp
|
||
className={cn(
|
||
"size-4 opacity-60 transition-transform duration-200",
|
||
showAbove ? "rotate-180" : "",
|
||
)}
|
||
/>
|
||
}
|
||
></ChainOfThoughtStep>
|
||
</Button>
|
||
)}
|
||
{lastToolCallStep && (
|
||
<ChainOfThoughtContent className="px-4 pb-2">
|
||
{showAbove &&
|
||
aboveLastToolCallSteps.map((step) =>
|
||
step.type === "reasoning" ? (
|
||
<ChainOfThoughtStep
|
||
key={step.id}
|
||
label={
|
||
<StepReasoningMarkdown
|
||
content={step.reasoning ?? ""}
|
||
displaySources={displaySources}
|
||
isLoading={isLoading}
|
||
rehypePlugins={rehypePlugins}
|
||
onCitationClick={handleCitationClick}
|
||
/>
|
||
}
|
||
></ChainOfThoughtStep>
|
||
) : (
|
||
<ToolCall
|
||
key={step.id}
|
||
{...step}
|
||
isLoading={isLoading}
|
||
skillDisplayMap={skillDisplayMap}
|
||
ragModeEnabled={ragModeEnabled}
|
||
/>
|
||
),
|
||
)}
|
||
{lastToolCallStep && (
|
||
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
|
||
<ToolCall
|
||
key={lastToolCallStep.id}
|
||
{...lastToolCallStep}
|
||
isLast={true}
|
||
isLoading={isLoading}
|
||
skillDisplayMap={skillDisplayMap}
|
||
ragModeEnabled={ragModeEnabled}
|
||
/>
|
||
</FlipDisplay>
|
||
)}
|
||
</ChainOfThoughtContent>
|
||
)}
|
||
{lastReasoningStep && (
|
||
<>
|
||
<Button
|
||
key={lastReasoningStep.id}
|
||
className="w-full items-start justify-start text-left"
|
||
variant="ghost"
|
||
onClick={() => setShowLastThinking(!showLastThinking)}
|
||
>
|
||
<div className="flex w-full items-center justify-between">
|
||
<ChainOfThoughtStep
|
||
className="font-normal"
|
||
label={t.common.thinking}
|
||
icon={LightbulbIcon}
|
||
></ChainOfThoughtStep>
|
||
<div>
|
||
<ChevronUp
|
||
className={cn(
|
||
"text-muted-foreground size-4",
|
||
showLastThinking ? "" : "rotate-180",
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Button>
|
||
{showLastThinking && (
|
||
<ChainOfThoughtContent className="px-4 pb-2">
|
||
<ChainOfThoughtStep
|
||
key={lastReasoningStep.id}
|
||
label={
|
||
<StepReasoningMarkdown
|
||
content={lastReasoningStep.reasoning ?? ""}
|
||
displaySources={displaySources}
|
||
isLoading={isLoading}
|
||
rehypePlugins={rehypePlugins}
|
||
onCitationClick={handleCitationClick}
|
||
/>
|
||
}
|
||
></ChainOfThoughtStep>
|
||
</ChainOfThoughtContent>
|
||
)}
|
||
</>
|
||
)}
|
||
</ChainOfThought>
|
||
<SingleRoundRagCitationDrawer
|
||
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}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ToolCall({
|
||
id,
|
||
messageId,
|
||
name,
|
||
args,
|
||
result,
|
||
isLast = false,
|
||
isLoading = false,
|
||
skillDisplayMap = new Map(),
|
||
ragModeEnabled = false,
|
||
}: {
|
||
id?: string;
|
||
messageId?: string;
|
||
name: string;
|
||
args: Record<string, unknown>;
|
||
result?: string | Record<string, unknown>;
|
||
isLast?: boolean;
|
||
isLoading?: boolean;
|
||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||
ragModeEnabled?: boolean;
|
||
}) {
|
||
const { t } = useI18n();
|
||
const displayMatch =
|
||
result !== undefined && isDisplaySamplePayload(result)
|
||
? null
|
||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||
null);
|
||
const displayItems =
|
||
displayMatch && result !== undefined
|
||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||
...item,
|
||
index: index + 1,
|
||
skillName: displayMatch.skillName,
|
||
mode: item.displayMode ?? displayMatch.display.mode,
|
||
}))
|
||
: [];
|
||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||
const wikiSearchLabel = name === "llmwiki_search"
|
||
? wikiQuery
|
||
? `Wiki 文档检索 "${wikiQuery}"`
|
||
: "Wiki 文档检索"
|
||
: null;
|
||
if (displayItems.length > 0) {
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={
|
||
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||
? "检索参考来源"
|
||
: `获取 ${displayItems.length} 条结果`)
|
||
}
|
||
icon={SearchIcon}
|
||
>
|
||
<DisplayResultsList results={displayItems} />
|
||
</ChainOfThoughtStep>
|
||
);
|
||
}
|
||
|
||
// Deep Research uses a virtual tool call for each durable writing phase.
|
||
// Rendering it here keeps its lifecycle in the very same ChainOfThought
|
||
// component as ordinary Q&A retrieval and sandbox tool calls.
|
||
if (name === "deep_research_progress") {
|
||
return <DeepResearchProgressStep id={id} args={args} />;
|
||
}
|
||
|
||
const searchItems = normalizeSearchResults(result);
|
||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||
...item,
|
||
index: index + 1,
|
||
skillName: name,
|
||
mode: "citation" as const,
|
||
}));
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||
<DisplayResultsList results={defaultItems} />
|
||
</ChainOfThoughtStep>
|
||
);
|
||
}
|
||
if (name === "web_search" || searchItems.length > 0) {
|
||
const query = resolveSearchQuery(args, result);
|
||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||
if (query) {
|
||
label =
|
||
name === "web_search"
|
||
? `知识库检索 "${query}"`
|
||
: t.toolCalls.searchFor(query);
|
||
} else if (name === "web_search") {
|
||
label = "知识库检索";
|
||
}
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||
{searchItems.length > 0 && (
|
||
<WebSearchResultsList results={searchItems} />
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "image_search") {
|
||
let label: React.ReactNode = t.toolCalls.searchForRelatedImages;
|
||
if (typeof args.query === "string") {
|
||
label = t.toolCalls.searchForRelatedImagesFor(args.query);
|
||
}
|
||
const results = (
|
||
result as {
|
||
results: {
|
||
source_url: string;
|
||
thumbnail_url: string;
|
||
image_url: string;
|
||
title: string;
|
||
}[];
|
||
}
|
||
)?.results;
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
|
||
{Array.isArray(results) && (
|
||
<ChainOfThoughtSearchResults>
|
||
{Array.isArray(results) &&
|
||
results.map((item) => (
|
||
<Tooltip key={item.image_url} content={item.title}>
|
||
<a
|
||
className="size-24 overflow-hidden rounded-lg object-cover"
|
||
href={item.source_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<div className="bg-accent size-24">
|
||
<img
|
||
className="size-full object-cover"
|
||
src={item.thumbnail_url}
|
||
alt={item.title}
|
||
width={100}
|
||
height={100}
|
||
/>
|
||
</div>
|
||
</a>
|
||
</Tooltip>
|
||
))}
|
||
</ChainOfThoughtSearchResults>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "web_fetch") {
|
||
const url = (args as { url: string })?.url;
|
||
let title = url;
|
||
if (typeof result === "string") {
|
||
const potentialTitle = extractTitleFromMarkdown(result);
|
||
if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") {
|
||
title = potentialTitle;
|
||
}
|
||
}
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={t.toolCalls.viewWebPage}
|
||
icon={GlobeIcon}
|
||
>
|
||
<ChainOfThoughtSearchResult>
|
||
{url && (
|
||
<a
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="cursor-pointer"
|
||
>
|
||
{title}
|
||
</a>
|
||
)}
|
||
</ChainOfThoughtSearchResult>
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "ls") {
|
||
let description: string | undefined = (args as { description: string })
|
||
?.description;
|
||
if (!description) {
|
||
description = t.toolCalls.listFolder;
|
||
}
|
||
const path: string | undefined = (args as { path: string })?.path;
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
|
||
{path && (
|
||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||
{path}
|
||
</ChainOfThoughtSearchResult>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "read_file") {
|
||
let description: string | undefined = (args as { description: string })
|
||
?.description;
|
||
if (!description) {
|
||
description = t.toolCalls.readFile;
|
||
}
|
||
const { path } = args as { path: string; content: string };
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
|
||
{path && (
|
||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||
{path}
|
||
</ChainOfThoughtSearchResult>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "write_file" || name === "str_replace") {
|
||
let description: string | undefined = (args as { description: string })
|
||
?.description;
|
||
if (!description) {
|
||
description = t.toolCalls.writeFile;
|
||
}
|
||
return (
|
||
<ArtifactWriteToolCall
|
||
id={id}
|
||
messageId={messageId}
|
||
name={name}
|
||
args={args}
|
||
result={result}
|
||
description={description}
|
||
isLast={isLast}
|
||
isLoading={isLoading}
|
||
/>
|
||
);
|
||
} else if (name === "bash") {
|
||
const description: string | undefined = (args as { description: string })
|
||
?.description;
|
||
if (!description) {
|
||
return t.toolCalls.executeCommand;
|
||
}
|
||
const command: string | undefined = (args as { command: string })?.command;
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={description}
|
||
icon={SquareTerminalIcon}
|
||
>
|
||
{command && (
|
||
<CodeBlock
|
||
className="mx-0 cursor-pointer border-none px-0"
|
||
showLineNumbers={false}
|
||
language="bash"
|
||
code={command}
|
||
/>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "ask_clarification") {
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={t.toolCalls.needYourHelp}
|
||
icon={MessageCircleQuestionMarkIcon}
|
||
></ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "write_todos") {
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={t.toolCalls.writeTodos}
|
||
icon={ListTodoIcon}
|
||
></ChainOfThoughtStep>
|
||
);
|
||
} else if (name === "update_artifact") {
|
||
const title = typeof args.title === "string" ? args.title : undefined;
|
||
const type = args.type as string | undefined;
|
||
const label = title ?? (type === "code" ? "生成代码" : "生成文档");
|
||
return (
|
||
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
|
||
<ChainOfThoughtSearchResult>
|
||
{type === "code" ? "代码文件" : "Markdown 文档"}
|
||
</ChainOfThoughtSearchResult>
|
||
</ChainOfThoughtStep>
|
||
);
|
||
} else {
|
||
const description: string | undefined = (args as { description: string })
|
||
?.description;
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
label={description ?? t.toolCalls.useTool(name)}
|
||
icon={WrenchIcon}
|
||
></ChainOfThoughtStep>
|
||
);
|
||
}
|
||
}
|
||
|
||
function DeepResearchProgressStep({
|
||
id,
|
||
args,
|
||
}: {
|
||
id?: string;
|
||
args: Record<string, unknown>;
|
||
}) {
|
||
const label = typeof args.label === "string" && args.label.trim()
|
||
? args.label
|
||
: "正在撰写研究报告";
|
||
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
|
||
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
|
||
const active = args.status !== "complete";
|
||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
|
||
const thinkingContentRef = useRef<HTMLPreElement>(null);
|
||
const autoScrollEnabledRef = useRef(true);
|
||
const programmaticScrollRef = useRef(false);
|
||
const previousActiveRef = useRef(active);
|
||
|
||
useEffect(() => {
|
||
if (active && thinking) setThinkingOpen(true);
|
||
}, [active, thinking]);
|
||
|
||
useEffect(() => {
|
||
if (active && !previousActiveRef.current) {
|
||
autoScrollEnabledRef.current = true;
|
||
setAutoScrollPaused(false);
|
||
}
|
||
previousActiveRef.current = active;
|
||
}, [active]);
|
||
|
||
const scrollToLatestThinking = useCallback(() => {
|
||
if (!autoScrollEnabledRef.current) return;
|
||
const content = thinkingContentRef.current;
|
||
if (!content) return;
|
||
// Scroll events do not identify their initiator reliably across browsers.
|
||
// Mark this assignment so it never disables following as if it were a
|
||
// user drag; a user-originated scroll away from the bottom still pauses it.
|
||
programmaticScrollRef.current = true;
|
||
content.scrollTop = content.scrollHeight;
|
||
requestAnimationFrame(() => {
|
||
programmaticScrollRef.current = false;
|
||
});
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!active || !thinkingOpen || !thinking) return;
|
||
scrollToLatestThinking();
|
||
// A second frame covers the browser's delayed reflow of a growing <pre>.
|
||
const frame = requestAnimationFrame(scrollToLatestThinking);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
|
||
|
||
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
|
||
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
|
||
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
|
||
// Being at the bottom is still the follow state. Only an actual move away
|
||
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
|
||
if (scrollTop + clientHeight >= scrollHeight - 4) return;
|
||
autoScrollEnabledRef.current = false;
|
||
setAutoScrollPaused(true);
|
||
};
|
||
|
||
const resumeAutoScroll = () => {
|
||
autoScrollEnabledRef.current = true;
|
||
setAutoScrollPaused(false);
|
||
requestAnimationFrame(scrollToLatestThinking);
|
||
};
|
||
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
status={active ? "active" : "complete"}
|
||
icon={active
|
||
? <Loader2Icon className="text-primary size-4 animate-spin" />
|
||
: <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />}
|
||
label={
|
||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||
<span>{label}</span>
|
||
<span className={active ? "text-primary text-xs" : "text-muted-foreground text-xs"}>
|
||
{active ? "进行中" : "已完成"}
|
||
</span>
|
||
</span>
|
||
}
|
||
>
|
||
{detail && (
|
||
<div className="text-muted-foreground text-xs leading-5">
|
||
{detail}
|
||
</div>
|
||
)}
|
||
{thinking && (
|
||
<Collapsible className="mt-2" onOpenChange={setThinkingOpen} open={thinkingOpen}>
|
||
<CollapsibleTrigger asChild>
|
||
<Button
|
||
aria-label={thinkingOpen ? "收起模型思考" : "展开模型思考"}
|
||
className="h-7 gap-1.5 px-2 text-xs"
|
||
size="sm"
|
||
type="button"
|
||
variant="ghost"
|
||
>
|
||
<LightbulbIcon className="size-3.5 text-amber-600 dark:text-amber-300" />
|
||
<span>模型思考</span>
|
||
<span className="text-muted-foreground">{active ? "思考中" : "已完成"}</span>
|
||
<ChevronDownIcon className={cn("size-3.5 transition-transform", thinkingOpen && "rotate-180")} />
|
||
</Button>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
|
||
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
|
||
</p>
|
||
<div className="relative mt-1">
|
||
<pre
|
||
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
|
||
onScroll={pauseAutoScrollOnManualScroll}
|
||
ref={thinkingContentRef}
|
||
>
|
||
{thinking}
|
||
</pre>
|
||
{active && autoScrollPaused && (
|
||
<Button
|
||
aria-label="恢复思考内容自动下滑"
|
||
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
|
||
onClick={resumeAutoScroll}
|
||
size="sm"
|
||
title="恢复自动下滑"
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
<ChevronsDownIcon className="size-3.5" />
|
||
自动下滑
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
}
|
||
|
||
/** 文件卡片只显示文件名:宿主泄漏路径(F:\...\outputs\report.md)与虚拟路径
|
||
* (/mnt/user-data/outputs/report.md)统一收敛到最后一段,避免把服务端磁盘结构暴露给用户。 */
|
||
function artifactDisplayName(path: string): string {
|
||
const filename = path.replace(/\\/g, "/").split("/").filter(Boolean).at(-1);
|
||
return filename || path;
|
||
}
|
||
|
||
function ArtifactWriteToolCall({
|
||
id,
|
||
messageId,
|
||
name,
|
||
args,
|
||
result,
|
||
description,
|
||
isLoading,
|
||
}: {
|
||
id?: string;
|
||
messageId?: string;
|
||
name: "write_file" | "str_replace";
|
||
args: Record<string, unknown>;
|
||
result?: string | Record<string, unknown>;
|
||
description: string;
|
||
isLast: boolean;
|
||
isLoading: boolean;
|
||
}) {
|
||
const artifacts = useArtifactsOptional();
|
||
const openStreamedArtifact = artifacts?.openStreamedArtifact;
|
||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||
const content = typeof args.content === "string" ? args.content : "";
|
||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||
const hasUsablePath =
|
||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||
const sourcePath = hasUsablePath
|
||
? normalizedPath
|
||
: name === "write_file" && content.trim()
|
||
? provisionalWriteFilePath(content)
|
||
: null;
|
||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||
// tool call has ids so we can open a write-file: memory preview instead.
|
||
const artifactPath =
|
||
sourcePath && name === "write_file" && messageId && id
|
||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||
: null;
|
||
|
||
useEffect(() => {
|
||
if (
|
||
!openStreamedArtifact ||
|
||
!artifactPath ||
|
||
!previewKey ||
|
||
!isLoading ||
|
||
result !== undefined ||
|
||
!sourcePath ||
|
||
!languageOfStreamingArtifact(sourcePath) ||
|
||
!isAutoPreviewableArtifactPath(sourcePath)
|
||
) {
|
||
return;
|
||
}
|
||
openStreamedArtifact(artifactPath, previewKey);
|
||
}, [
|
||
artifactPath,
|
||
isLoading,
|
||
openStreamedArtifact,
|
||
previewKey,
|
||
result,
|
||
sourcePath,
|
||
]);
|
||
|
||
return (
|
||
<ChainOfThoughtStep
|
||
key={id}
|
||
className={cn(artifactPath && "cursor-pointer")}
|
||
label={description}
|
||
icon={NotebookPenIcon}
|
||
onClick={() => {
|
||
if (!artifactPath || !artifacts) return;
|
||
artifacts.select(artifactPath);
|
||
artifacts.setOpen(true);
|
||
}}
|
||
>
|
||
{(normalizedPath || sourcePath) && (
|
||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||
</ChainOfThoughtSearchResult>
|
||
)}
|
||
</ChainOfThoughtStep>
|
||
);
|
||
}
|
||
|
||
interface GenericCoTStep<T extends string = string> {
|
||
id?: string;
|
||
messageId?: string;
|
||
type: T;
|
||
}
|
||
|
||
interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
|
||
reasoning: string | null;
|
||
}
|
||
|
||
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
|
||
name: string;
|
||
args: Record<string, unknown>;
|
||
result?: string | Record<string, unknown>;
|
||
}
|
||
|
||
type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||
|
||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||
const steps: CoTStep[] = [];
|
||
for (const message of messages) {
|
||
if (message.type === "ai") {
|
||
const reasoning = extractReasoningContentFromMessage(message);
|
||
if (reasoning) {
|
||
const step: CoTReasoningStep = {
|
||
id: message.id,
|
||
messageId: message.id,
|
||
type: "reasoning",
|
||
reasoning: extractReasoningContentFromMessage(message),
|
||
};
|
||
steps.push(step);
|
||
}
|
||
for (const tool_call of message.tool_calls ?? []) {
|
||
if (tool_call.name === "task") {
|
||
continue;
|
||
}
|
||
const writePath =
|
||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||
? (tool_call.args as { path: string }).path
|
||
: "";
|
||
if (
|
||
(
|
||
tool_call.name === "write_file" ||
|
||
tool_call.name === "str_replace" ||
|
||
tool_call.name === "read_file" ||
|
||
tool_call.name === "read_multiple_files" ||
|
||
tool_call.name === "present_files"
|
||
) &&
|
||
isInterceptedWritingReportWrite(writePath, message.id, messages, writingMode)
|
||
) {
|
||
continue;
|
||
}
|
||
const step: CoTToolCallStep = {
|
||
id: tool_call.id,
|
||
messageId: message.id,
|
||
type: "toolCall",
|
||
name: tool_call.name,
|
||
args: tool_call.args,
|
||
};
|
||
const toolCallId = tool_call.id;
|
||
if (toolCallId) {
|
||
const toolCallResult = findToolCallResult(
|
||
toolCallId,
|
||
messages,
|
||
tool_call.name,
|
||
);
|
||
if (toolCallResult !== undefined) {
|
||
if (typeof toolCallResult === "string") {
|
||
try {
|
||
step.result = JSON.parse(toolCallResult) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
} catch {
|
||
step.result = toolCallResult;
|
||
}
|
||
} else {
|
||
step.result = toolCallResult;
|
||
}
|
||
}
|
||
}
|
||
steps.push(step);
|
||
}
|
||
}
|
||
}
|
||
return steps;
|
||
}
|
||
|
||
function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||
<ChevronDownIcon
|
||
className={cn(
|
||
"size-3.5 transition-transform duration-200",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
<span>{results.length} 条检索结果</span>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||
{results.map((item, idx) => (
|
||
<SearchResultCard key={item.url ?? idx} item={item} />
|
||
))}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||
<ChevronDownIcon
|
||
className={cn(
|
||
"size-3.5 transition-transform duration-200",
|
||
open && "rotate-180",
|
||
)}
|
||
/>
|
||
<span>{results.length} 条真实返回结果</span>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||
{results.map((item) => (
|
||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||
))}
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
);
|
||
}
|
||
|
||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||
const [open, setOpen] = useState(false);
|
||
const content = item.snippet ?? item.content;
|
||
const date = item.published_date ?? item.publishedDate ?? item.date;
|
||
let sourceLabel: string | undefined = item.source;
|
||
if (item.url) {
|
||
try {
|
||
sourceLabel = new URL(item.url).hostname.replace(/^www\./, "");
|
||
} catch {
|
||
sourceLabel = sourceLabel ?? item.url;
|
||
}
|
||
}
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(true)}
|
||
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
||
>
|
||
{item.title && (
|
||
<div className="text-foreground line-clamp-1 text-sm font-medium">
|
||
{item.title}
|
||
</div>
|
||
)}
|
||
{(sourceLabel || date) && (
|
||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||
{sourceLabel && <span className="truncate">{sourceLabel}</span>}
|
||
{sourceLabel && date && <span>·</span>}
|
||
{date && <span>{date}</span>}
|
||
</div>
|
||
)}
|
||
{content && (
|
||
<div className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||
{content}
|
||
</div>
|
||
)}
|
||
</button>
|
||
<Sheet open={open} onOpenChange={setOpen}>
|
||
<SheetContent
|
||
side="right"
|
||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||
>
|
||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||
<SheetTitle className="text-base leading-relaxed">
|
||
{item.title ?? "检索结果"}
|
||
</SheetTitle>
|
||
{(sourceLabel || date) && (
|
||
<SheetDescription className="flex items-center gap-2 text-xs">
|
||
{sourceLabel && <span>{sourceLabel}</span>}
|
||
{sourceLabel && date && <span>·</span>}
|
||
{date && <span>{date}</span>}
|
||
</SheetDescription>
|
||
)}
|
||
{!sourceLabel && !date && (
|
||
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
|
||
)}
|
||
</SheetHeader>
|
||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
||
<div className="mx-auto max-w-3xl space-y-4">
|
||
{content ? (
|
||
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
|
||
{content}
|
||
</article>
|
||
) : (
|
||
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||
暂无可展示的检索内容
|
||
</div>
|
||
)}
|
||
{item.url && (
|
||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||
{item.url}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</>
|
||
);
|
||
}
|