diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIChainOfThought.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIChainOfThought.tsx index 8f73a0080..d0d843140 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIChainOfThought.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/displays/AIChainOfThought.tsx @@ -2,13 +2,17 @@ import type { CollapseCssRef } from "@follow/components/ui/collapse/CollapseCss. import { CollapseCss, CollapseCssGroup } from "@follow/components/ui/collapse/CollapseCss.js" import { ShinyText } from "@follow/components/ui/shiny-text/ShinyText.js" import { cn } from "@follow/utils" -import type { ReasoningUIPart } from "ai" +import type { BizUITools } from "@folo-services/ai-tools" +import type { ReasoningUIPart, ToolUIPart } from "ai" +import { isToolUIPart } from "ai" import * as React from "react" +import { ToolInvocationComponent } from "../message/ToolInvocationComponent" import { AIReasoningPart } from "./AIReasoningPart" +export type ChainReasoningPart = ReasoningUIPart | ToolUIPart interface AIChainOfThoughtProps { - groups: ReadonlyArray + groups: ReadonlyArray isStreaming?: boolean className?: string } @@ -17,14 +21,34 @@ export const AIChainOfThought: React.FC = React.memo( const collapseId = React.useMemo(() => `chain-${Math.random().toString(36).slice(2)}`, []) const collapseRef = React.useRef(null) - const lastPartText = groups.at?.(-1)?.text + const currentChainReasoningIsFinished = React.useMemo(() => { - return groups.every((part) => part.state === "done") + let allDone = true + for (const part of groups) { + if (isToolUIPart(part)) { + continue + } + if (part.state !== "done") { + allDone = false + break + } + } + return allDone }, [groups]) const currentReasoningTitle = React.useMemo(() => { if (!isStreaming) return null + + const lastPart = groups.at?.(-1) + + if (!lastPart) return null + + if (isToolUIPart(lastPart)) { + return `Calling ${lastPart.type.replace("tool-", "")}` + } + + const lastPartText = lastPart.text return extractHeading(lastPartText) - }, [isStreaming, lastPartText]) + }, [groups, isStreaming]) React.useEffect(() => { collapseRef.current?.setIsOpened(!currentChainReasoningIsFinished) @@ -65,20 +89,22 @@ export const AIChainOfThought: React.FC = React.memo(
{groups.map((part, index) => { + const innerCollapseId = `${collapseId}-${index}` + if (isToolUIPart(part)) { + return + } const mergedText = part.text const title = extractHeading(part.text) const groupStreaming = part.state === "streaming" - const innerCollapseId = `${collapseId}-${index}` - return (
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx index a59a311ea..8d840cbd9 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx @@ -21,6 +21,7 @@ import { AIDisplayFeedPart, AIDisplaySubscriptionsPart, } from "../displays" +import type { ChainReasoningPart } from "../displays/AIChainOfThought" import { AIMarkdownStreamingMessage } from "./AIMarkdownMessage" import { ToolInvocationComponent } from "./ToolInvocationComponent" @@ -51,29 +52,47 @@ export const AIMessageParts: React.FC = React.memo( }, [chatStatus, isLastMessage, shouldStreamingAnimation]) const displayParts = React.useMemo(() => { - const parts = [] as (ReasoningUIPart[] | TextUIPart | ToolUIPart)[] + const parts = [] as (ChainReasoningPart[] | TextUIPart | ToolUIPart)[] - const chainReasoningParts: ReasoningUIPart[] = [] + let chainReasoningParts: ChainReasoningPart[] | null = null for (const part of message.parts) { - if (part.type !== "reasoning") { - parts.push(chainReasoningParts.concat()) - chainReasoningParts.length = 0 + const isReasoning = part.type === "reasoning" && !!(part as ReasoningUIPart).text + const isTool = part.type.startsWith("tool-") + + if (isReasoning) { + if (!chainReasoningParts) { + chainReasoningParts = [] + // insert by reference once; keep appending to the same array thereafter + parts.push(chainReasoningParts) + } + chainReasoningParts.push(part as ReasoningUIPart) + continue } + + if (isTool) { + if (chainReasoningParts && chainReasoningParts.length > 0) { + chainReasoningParts.push(part as ToolUIPart) + } else { + parts.push(part as ToolUIPart) + } + continue + } + + // Only add text to top-level; do not break an active chain if (part.type === "text") { parts.push(part) - } else if (part.type.startsWith("tool-")) { - parts.push(part as ToolUIPart) - } else if (part.type === "reasoning" && part.text) { - chainReasoningParts.push(part as ReasoningUIPart) + continue } + + // Unknown/meta parts (e.g., step-start, source) are skipped here without breaking an active chain } - if (chainReasoningParts.length > 0) { - parts.push(chainReasoningParts.concat()) - } + // No final flush needed; chain array already referenced in parts return parts }, [message.parts]) + // console.info("displayParts", displayParts) + const lowPriorityParts = React.useDeferredValue(displayParts) return ( @@ -82,7 +101,7 @@ export const AIMessageParts: React.FC = React.memo( const partKey = `${message.id}-${index}` if (Array.isArray(partOrParts)) { - const reasoningParts = partOrParts as ReasoningUIPart[] + const reasoningParts = partOrParts as ChainReasoningPart[] return ( = R const hasResult = "output" in part && part.output const hasArgs = "input" in part && part.input + const isCalling = part.state === "input-streaming" + // Generate a unique value for this accordion item const accordionValue = `tool-${"toolCallId" in part ? part.toolCallId : Math.random()}` return ( -
-
- -
- - -
- - {hasError ? "Tool Failed:" : "Tool Called:"} -

- {toolName} -

-
- - {/* Custom arrow that only shows on hover */} -
- -
-
- } - contentClassName="pb-0 pt-2" - > -
- {/* Show tool arguments if available */} - {hasArgs ? ( -
-
Arguments:
- -
- ) : null} - - {/* Show tool result if available */} - {hasResult ? ( -
-
Result:
- -
- ) : null} - - {/* Show error if available */} - {hasError && "errorText" in part ? ( -
-
Error:
-
-                      {String(part.errorText)}
-                    
-
- ) : null} -
- - +
+
+
+ + + +
+ + {hasError ? "Tool Failed:" : isCalling ? "Tool Calling:" : "Tool Called:"} + + + {toolName} + +
+
+ +
+
+ } + contentClassName="pb-0 pt-2" + > +
+ {/* Show tool arguments if available */} + {hasArgs ? ( +
+
Arguments:
+ +
+ ) : null} + + {/* Show tool result if available */} + {hasResult ? ( +
+
Result:
+ +
+ ) : null} + + {/* Show error if available */} + {hasError && "errorText" in part ? ( +
+
Error:
+
+                    {String(part.errorText)}
+                  
+
+ ) : null} +
+ +
) }, diff --git a/apps/mobile/ios/Folo/Info.plist b/apps/mobile/ios/Folo/Info.plist index bfd816aad..0c6b650fd 100644 --- a/apps/mobile/ios/Folo/Info.plist +++ b/apps/mobile/ios/Folo/Info.plist @@ -53,7 +53,7 @@ CFBundleVersion - 135 + 136 ITSAppUsesNonExemptEncryption LSApplicationCategoryType