feat: aggregated ai chain of thought

This commit is contained in:
DIYgod 2025-09-18 11:55:45 +08:00
parent 4d4dce682c
commit 48e84ca209
No known key found for this signature in database
8 changed files with 166 additions and 79 deletions

View File

@ -0,0 +1,60 @@
import { CollapseCss, CollapseCssGroup } from "@follow/components/ui/collapse/CollapseCss.js"
import { cn } from "@follow/utils"
import * as React from "react"
interface AIChainOfThoughtProps {
children: React.ReactNode
isStreaming?: boolean
className?: string
}
export const AIChainOfThought: React.FC<AIChainOfThoughtProps> = React.memo(
({ children, isStreaming = false, className }) => {
const collapseId = React.useMemo(() => `chain-${Math.random().toString(36).slice(2)}`, [])
// Re-mount CollapseCssGroup when streaming state changes or when we need to force-open while streaming
const [remountTick, setRemountTick] = React.useState(0)
const groupKey = `${isStreaming ? "streaming" : "idle"}:${remountTick}`
if (!children) return null
return (
<div className={cn("border-border min-w-0 max-w-full text-left", className)}>
<div className="w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full" />
<CollapseCssGroup key={groupKey}>
<div>
<CollapseCss
hideArrow
collapseId={collapseId}
defaultOpen={isStreaming}
onOpenChange={(opened) => {
// While streaming, keep it open and block manual collapse
if (isStreaming && !opened) {
setRemountTick((x) => x + 1)
}
}}
title={
<div className="group flex h-6 min-w-0 flex-1 items-center py-0">
<div className="flex items-center gap-2 text-xs">
{/* <i className="i-mgc-brain-cute-re text-purple" /> */}
<span className="text-text-secondary">
{isStreaming ? "Thinking..." : "Finished Thinking"}
</span>
</div>
<div className="ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<i className="i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
</div>
</div>
}
className="group w-full border-none"
contentClassName="pb-2 pt-1"
>
{children}
</CollapseCss>
</div>
</CollapseCssGroup>
</div>
)
},
)
AIChainOfThought.displayName = "AIChainOfThought"

View File

@ -33,19 +33,17 @@ export const AIReasoningPart: React.FC<AIReasoningPartProps> = React.memo(
}
}}
title={
<div className="group flex h-6 min-w-0 flex-1 items-center justify-between py-0">
<div className="flex items-center gap-2 text-xs">
<i className="i-mingcute-bulb-2-line text-purple" />
<span className="text-text-secondary">
{isStreaming ? "Reasoning..." : "Reasoning"}
</span>
<div className="group/inner flex h-6 min-w-0 flex-1 items-center py-0">
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i className="i-mgc-brain-cute-re" />
<span>{isStreaming ? "Reasoning..." : "Reasoning"}</span>
</div>
<div className="ml-auto flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<i className="i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
<div className="ml-2 flex items-center justify-center opacity-0 transition-opacity duration-200 group-hover/inner:opacity-100">
<i className="i-mgc-right-cute-re size-3 shrink-0 transition-transform duration-200 group-data-[state=open]/inner:rotate-90" />
</div>
</div>
}
className="group w-full border-none"
className="group/inner w-full border-none"
contentClassName="pb-0 pt-2"
>
<div className="text-xs">

View File

@ -1,3 +1,4 @@
export { AIChainOfThought } from "./AIChainOfThought"
export { AIDisplayAnalyticsPart } from "./AIDisplayAnalyticsPart"
export { AIDisplayEntriesPart } from "./AIDisplayEntriesPart"
export { AIDisplayFeedPart } from "./AIDisplayFeedsPart"

View File

@ -351,7 +351,7 @@ export const AIMarkdownStreamingMessage = memo(
className?: string
isProcessing?: boolean
}) => {
const className = `prose max-w-full dark:prose-invert text-sm
const className = `prose max-w-full dark:prose-invert prose-sm
prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm
prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8`

View File

@ -14,7 +14,7 @@ export const AIMarkdownStreamingMessage = memo(
className?: string
isStreaming?: boolean
}) => {
const className = `prose max-w-full dark:prose-invert text-sm
const className = `prose max-w-full dark:prose-invert prose-sm
prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-h4:text-base prose-h5:text-base prose-h6:text-sm
prose-li:list-disc prose-li:marker:text-accent prose-hr:border-border prose-hr:mx-8`

View File

@ -16,6 +16,7 @@ import type {
import { useChatStatus } from "../../store/hooks"
import {
AIChainOfThought,
AIDisplayAnalyticsPart,
AIDisplayEntriesPart,
AIDisplayFeedPart,
@ -51,79 +52,107 @@ export const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(
return chatStatus === "streaming" && shouldStreamingAnimation && isLastMessage
}, [chatStatus, isLastMessage, shouldStreamingAnimation])
return message.parts.map((part, index) => {
const partKey = `${message.id}-${index}`
const displayParts = React.useMemo(() => {
return message.parts.filter(
(part) => part.type === "text" || part.type.startsWith("tool-display"),
)
}, [message.parts])
const thoughtParts = React.useMemo(() => {
return message.parts.filter(
(part) => !(part.type === "text" || part.type.startsWith("tool-display")),
)
}, [message.parts])
switch (part.type) {
case "text": {
return (
<AIMarkdownStreamingMessage
key={partKey}
text={part.text}
className={"text-text"}
isStreaming={shouldMessageAnimation}
/>
)
}
return (
<>
<AIChainOfThought isStreaming={displayParts.length === 0}>
{thoughtParts.map((part, index) => {
const partKey = `${message.id}-${index}`
case "reasoning": {
return (
<AIReasoningPart
key={partKey}
text={part.text}
isStreaming={part.state === "streaming"}
/>
)
}
switch (part.type) {
case "reasoning": {
return (
<AIReasoningPart
key={partKey}
text={part.text}
isStreaming={part.state === "streaming"}
/>
)
}
default: {
if (part.type.startsWith("tool-")) {
if (part.type.startsWith("tool-chunkBreak")) {
return null
}
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />
}
return null
}
}
})}
</AIChainOfThought>
{displayParts.map((part, index) => {
const partKey = `${message.id}-${index}`
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
case "tool-displayEntries": {
return <AIDisplayEntriesPart key={partKey} part={part as AIDisplayEntriesTool} />
}
case "tool-displaySubscriptions": {
return (
<AIDisplaySubscriptionsPart key={partKey} part={part as AIDisplaySubscriptionsTool} />
)
}
case "tool-displayFeed": {
return <AIDisplayFeedPart key={partKey} part={part as AIDisplayFeedTool} />
}
switch (part.type) {
case "text": {
return (
<AIMarkdownStreamingMessage
key={partKey}
text={part.text}
className={"text-text"}
isStreaming={shouldMessageAnimation}
/>
)
}
case "tool-displayFlowChart": {
const loadingElement = (
<div className="bg-material-medium my-2 flex aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full items-center justify-center rounded">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<i className="i-mgc-loading-3-cute-re text-text-secondary size-4 animate-spin" />
<span className="text-text-secondary text-sm font-medium">
Generating Flow Chart...
</span>
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
case "tool-displayEntries": {
return <AIDisplayEntriesPart key={partKey} part={part as AIDisplayEntriesTool} />
}
case "tool-displaySubscriptions": {
return (
<AIDisplaySubscriptionsPart
key={partKey}
part={part as AIDisplaySubscriptionsTool}
/>
)
}
case "tool-displayFeed": {
return <AIDisplayFeedPart key={partKey} part={part as AIDisplayFeedTool} />
}
case "tool-displayFlowChart": {
const loadingElement = (
<div className="bg-material-medium my-2 flex aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full items-center justify-center rounded">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<i className="i-mgc-loading-3-cute-re text-text-secondary size-4 animate-spin" />
<span className="text-text-secondary text-sm font-medium">
Generating Flow Chart...
</span>
</div>
</div>
</div>
</div>
</div>
)
return (
<ErrorBoundary key={partKey} beforeCapture={alwaysFalse}>
<React.Suspense fallback={loadingElement}>
<LazyAIDisplayFlowPart part={part as AIDisplayFlowTool} />
</React.Suspense>
</ErrorBoundary>
)
}
)
return (
<ErrorBoundary key={partKey} beforeCapture={alwaysFalse}>
<React.Suspense fallback={loadingElement}>
<LazyAIDisplayFlowPart part={part as AIDisplayFlowTool} />
</React.Suspense>
</ErrorBoundary>
)
}
default: {
if (part.type.startsWith("tool-")) {
if (part.type.startsWith("tool-chunkBreak")) {
default: {
return null
}
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />
}
return null
}
}
})
})}
</>
)
},
)

View File

@ -32,13 +32,11 @@ export const ToolInvocationComponent: React.FC<ToolInvocationComponentProps> = R
className="group border-none"
title={
<div className="group flex h-6 min-w-0 flex-1 items-center justify-between py-0">
<div className="flex items-center gap-2 text-xs">
<div className="text-text-secondary flex items-center gap-2 text-xs">
<i
className={hasError ? "i-mgc-close-cute-re text-red" : "i-mgc-tool-cute-re"}
/>
<span className="text-text-secondary">
{hasError ? "Tool Failed:" : "Tool Called:"}
</span>
<span>{hasError ? "Tool Failed:" : "Tool Called:"}</span>
<h4 className={`truncate font-medium ${hasError ? "text-red" : "text-text"}`}>
{toolName}
</h4>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" d="M6 9v1a1 1 0 0 0 1-1zm3 2a1 1 0 1 0 0 2zm2 4a1 1 0 1 0 2 0zm-3.667.772a1 1 0 1 0-.666-1.886zM18 9h-1a1 1 0 0 0 1 1zm-3 4a1 1 0 1 0 0-2zm2.333.886a1 1 0 1 0-.666 1.886zM11 7v10h2V7zM7 9V7H5v2zm0 8v-2H5v2zm2 2a2 2 0 0 1-2-2H5a4 4 0 0 0 4 4zm2-2a2 2 0 0 1-2 2v2a4 4 0 0 0 4-4zM9 5a2 2 0 0 1 2 2h2a4 4 0 0 0-4-4zm0-2a4 4 0 0 0-4 4h2a2 2 0 0 1 2-2zm0 10a2 2 0 0 1 2 2h2a4 4 0 0 0-4-4zm-3 1a2 2 0 0 1-2-2H2a4 4 0 0 0 4 4zm-2-2a2 2 0 0 1 2-2V8a4 4 0 0 0-4 4zm2.667 1.886A1.996 1.996 0 0 1 6 14v2c.466 0 .915-.08 1.333-.228zM19 9V7h-2v2zm0 8v-2h-2v2zm-4 4a4 4 0 0 0 4-4h-2a2 2 0 0 1-2 2zm-4-4a4 4 0 0 0 4 4v-2a2 2 0 0 1-2-2zm4-14a4 4 0 0 0-4 4h2a2 2 0 0 1 2-2zm0 2a2 2 0 0 1 2 2h2a4 4 0 0 0-4-4zm0 6a4 4 0 0 0-4 4h2a2 2 0 0 1 2-2zm3 5a4 4 0 0 0 4-4h-2a2 2 0 0 1-2 2zm4-4a4 4 0 0 0-4-4v2a2 2 0 0 1 2 2zm-5.333 3.772c.418.148.867.228 1.333.228v-2c-.235 0-.46-.04-.667-.114z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB