mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
feat: Group Reasoning with Tool Calls and Polish Activity UI
- Treat reasoning (Thoughts) as transparent to tool grouping so interleaved thoughts fold into the tool group instead of splitting it - Group a lone tool call that has reasoning (e.g. a skill) so it gets the same collapsible chrome as multi-tool groups - Count only real tool calls for the group header; add a reasoning indicator and a singular 'Used 1 tool' label - Rework the in-group Thoughts panel: tool-row sizing, rounded content, header copy button, and a floating collapse/copy bar - Round the floating thinking-bar buttons - Refine the agent handoff row and instructions panel with consistent spacing and a copy affordance
This commit is contained in:
parent
3c3837bb7d
commit
71427f9149
9 changed files with 396 additions and 52 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { MouseEvent } from 'react';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -16,6 +18,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
|
||||
|
||||
const targetAgentId = useMemo(() => {
|
||||
|
|
@ -44,9 +47,24 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
}, [_args]) as string;
|
||||
|
||||
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
|
||||
const agentName = targetAgent?.name || localize('com_ui_agent');
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(args);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
[args],
|
||||
);
|
||||
|
||||
const copyLabel = isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_to_clipboard');
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<div className="my-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
@ -58,9 +76,9 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
disabled={!hasInfo}
|
||||
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
|
||||
aria-expanded={hasInfo ? showInfo : undefined}
|
||||
aria-label={`${localize('com_ui_transferred_to')} ${targetAgent?.name || localize('com_ui_agent')}`}
|
||||
aria-label={`${localize('com_ui_transferred_to')} ${agentName}`}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
|
||||
<MessageIcon
|
||||
message={
|
||||
{
|
||||
|
|
@ -72,12 +90,13 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
/>
|
||||
</div>
|
||||
<span className="select-none">{localize('com_ui_transferred_to')}</span>
|
||||
<span className="select-none font-medium text-text-primary">
|
||||
{targetAgent?.name || localize('com_ui_agent')}
|
||||
</span>
|
||||
<span className="select-none font-medium text-text-primary">{agentName}</span>
|
||||
{hasInfo && (
|
||||
<ChevronDown
|
||||
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
|
||||
showInfo && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -85,11 +104,37 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
<div style={expandStyle}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{hasInfo && (
|
||||
<div className="ml-8 mt-2 rounded-lg border border-border-light bg-surface-secondary p-3 text-xs">
|
||||
<div className="mb-1 font-medium text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}:
|
||||
<div className="group/handoff my-2 ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
{localize('com_ui_handoff_instructions')}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={copyLabel}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copyLabel}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150',
|
||||
'opacity-0 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckMark className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Clipboard size="14" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap leading-relaxed text-text-primary">
|
||||
{args}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import { memo, useMemo, useState, useCallback, useRef, useId } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Lightbulb, ChevronDown } from 'lucide-react';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
|
||||
import type { MouseEvent, FocusEvent } from 'react';
|
||||
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
import { showThinkingAtom } from '~/store/showThinking';
|
||||
import { fontSizeAtom } from '~/store/fontSize';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const stripThinkTags = (reasoning: string): string =>
|
||||
reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
.replace(/\s*<\/think>$/, '')
|
||||
.trim();
|
||||
|
||||
type ReasoningProps = {
|
||||
reasoning: string;
|
||||
isLast: boolean;
|
||||
|
|
@ -46,12 +55,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
|||
const { isSubmitting, isLatestMessage, nextType } = useMessageContext();
|
||||
|
||||
// Strip <think> tags from the reasoning content (modern format)
|
||||
const reasoningText = useMemo(() => {
|
||||
return reasoning
|
||||
.replace(/^<think>\s*/, '')
|
||||
.replace(/\s*<\/think>$/, '')
|
||||
.trim();
|
||||
}, [reasoning]);
|
||||
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -133,4 +137,142 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
Reasoning.displayName = 'Reasoning';
|
||||
|
||||
type ReasoningCompactProps = {
|
||||
reasoning: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact reasoning row for use INSIDE a ToolCallGroup. Keeps the tool-row
|
||||
* header rhythm (icon + label + chevron) so an interleaved thought reads as a
|
||||
* sibling of the surrounding tool calls, while retaining the standalone
|
||||
* {@link Reasoning} affordances — a hover-revealed copy button on the header and
|
||||
* a floating collapse + copy bar inside the rounded content panel.
|
||||
*/
|
||||
export const ReasoningCompact = memo(({ reasoning, label }: ReasoningCompactProps) => {
|
||||
const contentId = useId();
|
||||
const localize = useLocalize();
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
const showThinking = useAtomValue(showThinkingAtom);
|
||||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const [isBarVisible, setIsBarVisible] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded);
|
||||
|
||||
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
|
||||
|
||||
const handleToggle = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(reasoningText);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
[reasoningText],
|
||||
);
|
||||
|
||||
const revealBar = useCallback(() => setIsBarVisible(true), []);
|
||||
const hideBar = useCallback(() => {
|
||||
if (!containerRef.current?.contains(document.activeElement)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
const handleBlur = useCallback((e: FocusEvent) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsBarVisible(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copyLabel = isCopied
|
||||
? localize('com_ui_copied_to_clipboard')
|
||||
: localize('com_ui_copy_thoughts_to_clipboard');
|
||||
|
||||
if (!reasoningText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/reasoning-compact"
|
||||
onMouseEnter={revealBar}
|
||||
onMouseLeave={hideBar}
|
||||
onFocus={revealBar}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<div className="relative my-1.5 flex h-5 shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className="inline-flex min-w-0 flex-1 items-center gap-2 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
>
|
||||
<Lightbulb className="size-4 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<span className="tool-status-text font-medium">{label}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 translate-y-[1px] text-text-secondary transition-transform duration-200 ease-out',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<TooltipAnchor
|
||||
description={copyLabel}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={copyLabel}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150',
|
||||
'opacity-0 group-focus-within/reasoning-compact:opacity-100 group-hover/reasoning-compact:opacity-100',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckMark className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Clipboard size="16" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id={contentId}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
aria-hidden={!isExpanded || undefined}
|
||||
style={expandStyle}
|
||||
>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="relative my-2 rounded-2xl border border-border-light bg-surface-secondary p-4 pb-9 text-text-secondary">
|
||||
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{reasoningText}</p>
|
||||
<FloatingThinkingBar
|
||||
isVisible={isBarVisible && isExpanded}
|
||||
isExpanded={isExpanded}
|
||||
onClick={handleToggle}
|
||||
content={reasoningText}
|
||||
contentId={contentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReasoningCompact.displayName = 'ReasoningCompact';
|
||||
|
||||
export default Reasoning;
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export const FloatingThinkingBar = memo(
|
|||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
|
||||
'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
|
|
@ -207,7 +207,7 @@ export const FloatingThinkingBar = memo(
|
|||
onClick={handleCopy}
|
||||
aria-label={copyTooltip}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded p-1.5 text-text-tertiary',
|
||||
'flex items-center justify-center rounded-lg p-1.5 text-text-tertiary',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * from './Attachment';
|
|||
export * from './OpenAIImageGen';
|
||||
|
||||
export { default as Text } from './Text';
|
||||
export { default as Reasoning } from './Reasoning';
|
||||
export { default as Reasoning, ReasoningCompact } from './Reasoning';
|
||||
export { default as EmptyText } from './EmptyText';
|
||||
export { default as LogContent } from './LogContent';
|
||||
export { default as ExecuteCode } from './ExecuteCode';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ChevronDown, Users } from 'lucide-react';
|
||||
import { ChevronDown, Users, Lightbulb } from 'lucide-react';
|
||||
import { Tools, Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TAttachment,
|
||||
|
|
@ -10,12 +10,12 @@ import type {
|
|||
} from 'librechat-data-provider';
|
||||
import type { PartWithIndex } from './ParallelContent';
|
||||
import { useLocalize, useExpandCollapse, scheduleMessageContentLayoutReconcile } from '~/hooks';
|
||||
import { AttachmentGroup, ReasoningCompact } from './Parts';
|
||||
import { isBashProgrammaticToolCall } from './routing';
|
||||
import { cn, getToolDisplayLabel } from '~/utils';
|
||||
import { StackedToolIcons } from './ToolOutput';
|
||||
import { useMCPIconMap } from '~/hooks/MCP';
|
||||
import { AttachmentGroup } from './Parts';
|
||||
import store from '~/store';
|
||||
import { isBashProgrammaticToolCall } from './routing';
|
||||
|
||||
interface ToolMeta {
|
||||
name: string;
|
||||
|
|
@ -106,15 +106,29 @@ export default function ToolCallGroup({
|
|||
const mcpIconMap = useMCPIconMap();
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const cancelLayoutReconcileRef = useRef<(() => void) | null>(null);
|
||||
const count = parts.length;
|
||||
|
||||
const toolMetadata = useMemo(() => parts.map((p) => getToolMeta(p.part)), [parts]);
|
||||
/** `parts` may include interleaved reasoning ("Thoughts") parts that render
|
||||
* inside the body but are not tools — count and summarize only the real tool
|
||||
* calls so the header reads "Used N tools" and the stacked icons stay clean. */
|
||||
const toolMetadata = useMemo(
|
||||
() => parts.map((p) => getToolMeta(p.part)).filter((m): m is ToolMeta => m != null),
|
||||
[parts],
|
||||
);
|
||||
const count = toolMetadata.length;
|
||||
const allCompleted = useMemo(
|
||||
() => toolMetadata.every((m) => m?.hasOutput === true),
|
||||
() => toolMetadata.every((m) => m.hasOutput === true),
|
||||
[toolMetadata],
|
||||
);
|
||||
const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]);
|
||||
const iconToolNames = useMemo(() => toolMetadata.map((m) => m?.iconName ?? ''), [toolMetadata]);
|
||||
const toolNames = useMemo(() => toolMetadata.map((m) => m.name), [toolMetadata]);
|
||||
const iconToolNames = useMemo(() => toolMetadata.map((m) => m.iconName), [toolMetadata]);
|
||||
|
||||
/** Reasoning interleaved with the tool calls renders inside the body but is
|
||||
* hidden while collapsed — surface a lightbulb in the header so the summary
|
||||
* hints that the group also contains thoughts. */
|
||||
const hasReasoning = useMemo(
|
||||
() => parts.some((p) => p.part.type === ContentTypes.THINK),
|
||||
[parts],
|
||||
);
|
||||
|
||||
/** Subagent tool calls get their own label verb ("Running/Ran N agents")
|
||||
* since "Used N tools" reads oddly when the "tools" are actually child
|
||||
|
|
@ -151,7 +165,10 @@ export default function ToolCallGroup({
|
|||
}, [toolNames, localize]);
|
||||
|
||||
const autoExpand = useRecoilValue(store.autoExpandTools);
|
||||
const autoCollapse = !autoExpand && count >= 2 && allCompleted;
|
||||
/** Every group has ≥1 tool; collapse a completed one by default just like a
|
||||
* multi-tool group, so a lone tool-with-thinking group (e.g. a skill) stays
|
||||
* visually consistent with the larger groups around it. */
|
||||
const autoCollapse = !autoExpand && count >= 1 && allCompleted;
|
||||
const initialState = initialExpansionState?.userOverride === true ? initialExpansionState : null;
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
initialState?.isExpanded ?? (autoExpand || !autoCollapse),
|
||||
|
|
@ -221,12 +238,14 @@ export default function ToolCallGroup({
|
|||
subagentsDone
|
||||
? localize('com_ui_ran_n_agents', { 0: String(count) })
|
||||
: localize('com_ui_running_n_agents', { 0: String(count) });
|
||||
const groupLabel = allSubagents
|
||||
? getSubagentLabel()
|
||||
: localize('com_ui_used_n_tools', { 0: String(count) });
|
||||
const getToolsLabel = () =>
|
||||
count === 1
|
||||
? localize('com_ui_used_one_tool')
|
||||
: localize('com_ui_used_n_tools', { 0: String(count) });
|
||||
const groupLabel = allSubagents ? getSubagentLabel() : getToolsLabel();
|
||||
|
||||
const hasActiveToolCall = useMemo(
|
||||
() => isSubmitting && toolMetadata.some((m) => m && !m.hasOutput),
|
||||
() => isSubmitting && toolMetadata.some((m) => !m.hasOutput),
|
||||
[toolMetadata, isSubmitting],
|
||||
);
|
||||
|
||||
|
|
@ -244,7 +263,7 @@ export default function ToolCallGroup({
|
|||
className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={groupLabel}
|
||||
aria-label={hasReasoning ? `${groupLabel}, ${localize('com_ui_thoughts')}` : groupLabel}
|
||||
>
|
||||
{allSubagents ? (
|
||||
/** Subagent groups don't have per-tool icons — StackedToolIcons
|
||||
|
|
@ -275,6 +294,9 @@ export default function ToolCallGroup({
|
|||
{toolNameSummary && !allSubagents && (
|
||||
<span className="text-xs font-normal text-text-secondary">— {toolNameSummary}</span>
|
||||
)}
|
||||
{hasReasoning && (
|
||||
<Lightbulb className="size-3.5 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out',
|
||||
|
|
@ -287,9 +309,24 @@ export default function ToolCallGroup({
|
|||
{shouldRenderBody && (
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
<div className="py-0.5 pl-4">
|
||||
{parts.map(({ part, idx }) =>
|
||||
renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand),
|
||||
)}
|
||||
{parts.map(({ part, idx }) => {
|
||||
if (part.type === ContentTypes.THINK) {
|
||||
const think = part.think;
|
||||
const reasoning = typeof think === 'string' ? think : (think?.value ?? '');
|
||||
const label =
|
||||
isSubmitting && idx === lastContentIdx
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts');
|
||||
return (
|
||||
<ReasoningCompact
|
||||
key={`reasoning-${idx}`}
|
||||
reasoning={reasoning}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button).toHaveAttribute('aria-label');
|
||||
expect(button.getAttribute('aria-label')).toContain('Transferred to');
|
||||
expect(button.getAttribute('aria-label')).toContain('Test Agent');
|
||||
|
|
@ -81,7 +81,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '{"key":"value"}',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button.className).toContain('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
|
|
@ -91,7 +91,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
args: '',
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1733,6 +1733,7 @@
|
|||
"com_ui_use_prompt": "Use Prompt",
|
||||
"com_ui_used": "Used",
|
||||
"com_ui_used_n_tools": "Used {{0}} tools",
|
||||
"com_ui_used_one_tool": "Used 1 tool",
|
||||
"com_ui_user": "User",
|
||||
"com_ui_user_group_permissions": "User & Group Permissions",
|
||||
"com_ui_user_provides_key": "Each user provides their own key",
|
||||
|
|
|
|||
95
client/src/utils/__tests__/groupToolCalls.test.ts
Normal file
95
client/src/utils/__tests__/groupToolCalls.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Constants, ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent';
|
||||
import { groupSequentialToolCalls } from '../groupToolCalls';
|
||||
|
||||
const toolPart = (id: string, name = 'fetch'): TMessageContentParts =>
|
||||
({
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: { id, name, args: '{}', output: 'done' },
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const thinkPart = (text = 'reasoning'): TMessageContentParts =>
|
||||
({ type: ContentTypes.THINK, think: text }) as unknown as TMessageContentParts;
|
||||
|
||||
const textPart = (text = 'answer'): TMessageContentParts =>
|
||||
({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts;
|
||||
|
||||
const transferPart = (): TMessageContentParts =>
|
||||
({
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
id: 'x',
|
||||
name: `${Constants.LC_TRANSFER_TO_}agent`,
|
||||
args: '{}',
|
||||
},
|
||||
}) as unknown as TMessageContentParts;
|
||||
|
||||
const withIndex = (parts: TMessageContentParts[]): PartWithIndex[] =>
|
||||
parts.map((part, idx) => ({ part, idx }));
|
||||
|
||||
describe('groupSequentialToolCalls', () => {
|
||||
it('groups two adjacent tool calls', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolPart('a'), toolPart('b')]));
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ type: 'tool-group' });
|
||||
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps a single tool call ungrouped', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolPart('a')]));
|
||||
expect(result).toEqual([{ type: 'single', part: { part: expect.anything(), idx: 0 } }]);
|
||||
});
|
||||
|
||||
it('absorbs reasoning interleaved between tool calls into one group', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolPart('a'), thinkPart(), toolPart('b')]));
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('absorbs leading and trailing reasoning around a tool run', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([thinkPart('lead'), toolPart('a'), toolPart('b'), thinkPart('trail')]),
|
||||
);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('groups a lone tool call wrapped in reasoning (e.g. a skill)', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([thinkPart(), toolPart('a'), thinkPart()]));
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool-group');
|
||||
expect(result[0].type === 'tool-group' && result[0].parts.map((p) => p.idx)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('keeps a lone tool call without reasoning inline', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([toolPart('a'), textPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
|
||||
it('keeps pure reasoning (no tool call) standalone', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([thinkPart(), textPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
|
||||
it('does not pull a thought-then-answer tail into a preceding group', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([toolPart('a'), toolPart('b'), textPart(), thinkPart(), textPart('final')]),
|
||||
);
|
||||
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'single', 'single']);
|
||||
});
|
||||
|
||||
it('splits tool runs separated by a non-reasoning part', () => {
|
||||
const result = groupSequentialToolCalls(
|
||||
withIndex([toolPart('a'), toolPart('b'), textPart(), toolPart('c'), toolPart('d')]),
|
||||
);
|
||||
expect(result.map((g) => g.type)).toEqual(['tool-group', 'single', 'tool-group']);
|
||||
});
|
||||
|
||||
it('does not group transfer/handoff tool calls', () => {
|
||||
const result = groupSequentialToolCalls(withIndex([transferPart(), transferPart()]));
|
||||
expect(result.map((g) => g.type)).toEqual(['single', 'single']);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,30 +22,54 @@ function isGroupableToolCall(part: TMessageContentParts): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** Reasoning ("Thoughts") parts are transparent to grouping: a thought
|
||||
* interleaved between tool calls joins the run instead of splitting it, so
|
||||
* reasoning models that think between every call still collapse into a single
|
||||
* tool group. A run becomes a group once it holds ≥2 tool calls, OR a single
|
||||
* tool call accompanied by reasoning — so a lone tool wrapped in thinking
|
||||
* (e.g. a skill invocation) still gets the grouped chrome with its thoughts
|
||||
* folded in. A run of pure reasoning (no tool call) keeps rendering as its own
|
||||
* standalone card. */
|
||||
function isReasoningPart(part: TMessageContentParts): boolean {
|
||||
return part.type === ContentTypes.THINK;
|
||||
}
|
||||
|
||||
function countToolCalls(parts: PartWithIndex[]): number {
|
||||
let count = 0;
|
||||
for (const { part } of parts) {
|
||||
if (isGroupableToolCall(part)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] {
|
||||
const result: GroupedPart[] = [];
|
||||
let currentGroup: PartWithIndex[] = [];
|
||||
let currentRun: PartWithIndex[] = [];
|
||||
|
||||
const flushGroup = () => {
|
||||
if (currentGroup.length >= 2) {
|
||||
result.push({ type: 'tool-group', parts: [...currentGroup] });
|
||||
const flushRun = () => {
|
||||
const toolCallCount = countToolCalls(currentRun);
|
||||
const hasReasoning = currentRun.some((p) => isReasoningPart(p.part));
|
||||
if (toolCallCount >= 2 || (toolCallCount >= 1 && hasReasoning)) {
|
||||
result.push({ type: 'tool-group', parts: [...currentRun] });
|
||||
} else {
|
||||
for (const p of currentGroup) {
|
||||
for (const p of currentRun) {
|
||||
result.push({ type: 'single', part: p });
|
||||
}
|
||||
}
|
||||
currentGroup = [];
|
||||
currentRun = [];
|
||||
};
|
||||
|
||||
for (const item of parts) {
|
||||
if (isGroupableToolCall(item.part)) {
|
||||
currentGroup.push(item);
|
||||
if (isGroupableToolCall(item.part) || isReasoningPart(item.part)) {
|
||||
currentRun.push(item);
|
||||
} else {
|
||||
flushGroup();
|
||||
flushRun();
|
||||
result.push({ type: 'single', part: item });
|
||||
}
|
||||
}
|
||||
flushGroup();
|
||||
flushRun();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue