From 4f1d2d494fb816a696554b705adfa05f54604d69 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 10 Apr 2026 02:00:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(conversation):=20assistant=20g?= =?UTF-8?q?roup=20workflow=20collapse=20and=20activate-tools=20inspector?= =?UTF-8?q?=20(#13696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(workflow): rewrite WorkflowSummary with status dot and minimal flat style * refactor(workflow): rewrite WorkflowCollapse with unified borderless container * โœจ feat(workflow): add WorkflowExpandedList component and fix type errors * โ™ป๏ธ refactor(workflow): add missing Workflow components with Minimal Flat design - WorkflowReasoningLine: cssVar tokens, aligned padding - WorkflowToolDetail: new expandable result panel with motion animation - WorkflowToolLine: expand chevron, getToolColor, detail panel integration - WorkflowExpandedList: flat rendering with reasoning + tool lines * Add tool call collapse support Made-with: Cursor * ๐Ÿ’„ style(workflow): align WorkflowCollapse UI with @lobehub/ui design system - Align border-radius, gap, padding tokens across all Workflow components - Replace chevron expand/collapse with status icons (CheckCircle2, CircleX, Loader2) - Use @lobehub/ui Highlighter for tool detail panel with JSON auto-formatting - Use @lobehub/ui Flexbox for WorkflowExpandedList with proper gap and padding - Fix delete action to use removeToolFromMessage instead of deleteAssistantMessage - Wire debug button to existing Tool/Debug panel with full tabs - Fix auto-collapse to only trigger on incompleteโ†’complete transition - Single ChevronDown with rotation for WorkflowSummary (match @lobehub/ui pattern) * ๐Ÿ’„ style(workflow): use AccordionItem and inspectorTextStyles for WorkflowCollapse - Replace custom WorkflowSummary with @lobehub/ui AccordionItem - Use StatusIndicator pattern (Block outlined 24x24) for status icon - Apply inspectorTextStyles.root for title text (colorTextSecondary) - Remove WorkflowSummary.tsx (dead code) - Match Tool component AccordionItem usage (paddingBlock/Inline=4, borderless) * ๐Ÿ’„ style(workflow): remove divider and gap from WorkflowExpandedList * ๐Ÿ’„ style(workflow): align WorkflowCollapse title bar with Thinking component * ๐Ÿ’„ style(workflow): unify inner item spacing, font size, and colors * โœจ feat(workflow): add streaming scroll behavior with max-height and auto-scroll * ๐Ÿ’„ refactor(assistant-group): refine workflow collapse UI and duration - Use Accordion for collapse; align tool/reasoning lines with generation state - Show workflow header duration from summed block performance, not reasoning only Made-with: Cursor * โœจ feat(inspector): enhance ActivateToolsInspector to display not found tools count - Added localization for not found tools message in English, Chinese, and default locales. - Updated ActivateToolsInspector to show a tooltip with the count of tools not found. - Modified StatusIndicator to support a warning state for scenarios where no tools are activated but some are not found. Signed-off-by: Innei * ๐Ÿ’„ style(workflow): simplify padding in WorkflowExpandedList component - Removed unnecessary paddingInline from Flexbox elements in WorkflowExpandedList for cleaner layout. Signed-off-by: Innei * โœจ feat(assistant-group): introduce constants and utility functions for workflow management - Added constants for workflow timing, limits, and tool display names to enhance the assistant group's functionality. - Implemented utility functions for processing and scoring post-tool answers, improving the workflow's response handling. - Created new components for rendering content blocks and managing scroll behavior in the assistant group. Signed-off-by: Innei * โœจ feat(assistant-group): enhance ContentBlock and Group components with content handling logic - Added logic to conditionally render message content based on content availability and tool presence in ContentBlock. - Introduced utility functions to determine substantive content and reasoning in Group, improving block partitioning for workflow management. - Updated partitioning logic to handle trailing reasoning candidates and streamline answer and working block separation. Signed-off-by: Innei * ๐Ÿ™ˆ chore(gitignore): clarify superpowers local paths Document that `.superpowers/` and `docs/superpowers/` are plugin/local outputs and must not be committed. Made-with: Cursor * ๐Ÿ‘ท chore(ci): restore auto-tag-release workflow from canary Revert unintended workflow edits so release tagging stays on main with sync-main-to-canary dispatch. Made-with: Cursor --------- Signed-off-by: Innei --- .gitignore | 3 +- locales/en-US/plugin.json | 1 + locales/zh-CN/plugin.json | 1 + .../client/Inspector/ActivateTools/index.tsx | 37 +- .../AssistantGroup/Tool/Actions/index.tsx | 14 +- .../Tool/Inspector/StatusIndicator.tsx | 8 +- .../AssistantGroup/Tool/Inspector/index.tsx | 33 +- .../components/ContentBlock.tsx | 20 +- .../components/ContentBlocksScroll.tsx | 100 ++++++ .../AssistantGroup/components/Group.tsx | 151 +++++++- .../components/WorkflowCollapse.tsx | 288 ++++++++++++++++ .../components/WorkflowExpandedList.tsx | 32 ++ .../components/WorkflowToolDetail.tsx | 48 +++ .../Messages/AssistantGroup/constants.ts | 207 +++++++++++ .../AssistantGroup/toolDisplayNames.test.ts | 56 +++ .../AssistantGroup/toolDisplayNames.ts | 324 ++++++++++++++++++ .../resolveAssistantGroupFromMessages.ts | 49 +++ .../Messages/Tasks/shared/TaskMessages.tsx | 88 +---- src/locales/default/plugin.ts | 1 + 19 files changed, 1369 insertions(+), 92 deletions(-) create mode 100644 src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx create mode 100644 src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx create mode 100644 src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx create mode 100644 src/features/Conversation/Messages/AssistantGroup/components/WorkflowToolDetail.tsx create mode 100644 src/features/Conversation/Messages/AssistantGroup/constants.ts create mode 100644 src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts create mode 100644 src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts create mode 100644 src/features/Conversation/Messages/AssistantGroup/utils/resolveAssistantGroupFromMessages.ts diff --git a/.gitignore b/.gitignore index cff93124fc..0f996411d0 100644 --- a/.gitignore +++ b/.gitignore @@ -144,5 +144,6 @@ spaHtmlTemplates.ts apps/desktop/resources/bin/lobe-cli.js apps/desktop/resources/cli-package.json +# Superpowers plugin brainstorm/spec outputs (local only; do not commit) .superpowers/ -docs/superpowers \ No newline at end of file +docs/superpowers/ \ No newline at end of file diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index e39d70fdf1..8e1c427090 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -2,6 +2,7 @@ "arguments.moreParams": "{{count}} params in total", "arguments.title": "Arguments", "builtins.lobe-activator.apiName.activateTools": "Activate Tools", + "builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} not found", "builtins.lobe-agent-builder.apiName.getAvailableModels": "Get available models", "builtins.lobe-agent-builder.apiName.getAvailableTools": "Get available Skills", "builtins.lobe-agent-builder.apiName.getConfig": "Get config", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index b9d5e88479..d5fdcf47d6 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -2,6 +2,7 @@ "arguments.moreParams": "็ญ‰ {{count}} ไธชๅ‚ๆ•ฐ", "arguments.title": "ๅ‚ๆ•ฐๅˆ—่กจ", "builtins.lobe-activator.apiName.activateTools": "ๆฟ€ๆดปๅทฅๅ…ท", + "builtins.lobe-activator.inspector.activateTools.notFoundCount": "{{count}} ไธชๆœชๆ‰พๅˆฐ", "builtins.lobe-agent-builder.apiName.getAvailableModels": "่Žทๅ–ๅฏ็”จๆจกๅž‹", "builtins.lobe-agent-builder.apiName.getAvailableTools": "่Žทๅ–ๅฏ็”จๆŠ€่ƒฝ", "builtins.lobe-agent-builder.apiName.getConfig": "่Žทๅ–้…็ฝฎ", diff --git a/packages/builtin-tool-activator/src/client/Inspector/ActivateTools/index.tsx b/packages/builtin-tool-activator/src/client/Inspector/ActivateTools/index.tsx index 636306914d..26cdf495a9 100644 --- a/packages/builtin-tool-activator/src/client/Inspector/ActivateTools/index.tsx +++ b/packages/builtin-tool-activator/src/client/Inspector/ActivateTools/index.tsx @@ -1,8 +1,9 @@ 'use client'; import { type BuiltinInspectorProps } from '@lobechat/types'; -import { Avatar } from '@lobehub/ui'; +import { Avatar, Flexbox, Icon, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cssVar, cx } from 'antd-style'; +import { AlertTriangle } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,6 +12,12 @@ import { inspectorTextStyles, shinyTextStyles } from '@/styles'; import type { ActivateToolsParams, ActivateToolsState } from '../../../types'; const styles = createStaticStyles(({ css }) => ({ + notFoundHint: css` + flex-shrink: 0; + max-width: 100%; + font-size: 12px; + color: ${cssVar.colorWarning}; + `, tool: css` display: inline-flex; gap: 2px; @@ -37,6 +44,7 @@ export const ActivateToolsInspector = memo< const identifiers = args?.identifiers || partialArgs?.identifiers; const activatedTools = pluginState?.activatedTools; + const notFoundList = pluginState?.notFound ?? []; // Streaming / Loading: show identifiers from arguments if (isArgumentsStreaming || isLoading) { @@ -56,10 +64,31 @@ export const ActivateToolsInspector = memo< ); } - // Finished: show activated tool names with avatars + // Finished: show activated tool names with avatars; surface notFound in the title row + const hasNotFound = notFoundList.length > 0; + const notFoundTitle = notFoundList.join(', '); + return ( -
+ {t('builtins.lobe-activator.apiName.activateTools')} + {hasNotFound && ( + + + + + {t('builtins.lobe-activator.inspector.activateTools.notFoundCount', { + count: notFoundList.length, + })} + + + + )} {activatedTools && activatedTools.length > 0 && ( {activatedTools.map((tool) => ( @@ -70,7 +99,7 @@ export const ActivateToolsInspector = memo< ))} )} -
+ ); }); diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Actions/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Actions/index.tsx index f303f27aa4..44f7665cea 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Actions/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Actions/index.tsx @@ -14,6 +14,8 @@ interface ActionsProps { setShowDebug?: (show: boolean) => void; showCustomToolRender?: boolean; showDebug?: boolean; + /** When set, trash removes only this tool from the message instead of deleting the assistant message */ + toolRemoval?: { messageId: string; toolCallId: string }; } const Actions = memo( @@ -25,9 +27,13 @@ const Actions = memo( setShowDebug, showCustomToolRender, showDebug, + toolRemoval, }) => { const { t } = useTranslation('plugin'); - const deleteAssistantMessage = useConversationStore((s) => s.deleteAssistantMessage); + const [deleteAssistantMessage, removeToolFromMessage] = useConversationStore((s) => [ + s.deleteAssistantMessage, + s.removeToolFromMessage, + ]); return ( <> @@ -55,7 +61,11 @@ const Actions = memo( size={'small'} title={t('inspector.delete')} onClick={() => { - deleteAssistantMessage(assistantMessageId); + if (toolRemoval) { + void removeToolFromMessage(toolRemoval.messageId, toolRemoval.toolCallId); + } else { + void deleteAssistantMessage(assistantMessageId); + } }} /> diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/StatusIndicator.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/StatusIndicator.tsx index 63b4dc84fb..8e9a6d7cb4 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/StatusIndicator.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/StatusIndicator.tsx @@ -1,7 +1,7 @@ import { type ToolIntervention } from '@lobechat/types'; import { Block, Icon, Tooltip } from '@lobehub/ui'; import { cssVar } from 'antd-style'; -import { Ban, Check, HandIcon, PauseIcon, X } from 'lucide-react'; +import { AlertTriangle, Ban, Check, HandIcon, PauseIcon, X } from 'lucide-react'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,9 +11,11 @@ import { LOADING_FLAT } from '@/const/message'; interface StatusIndicatorProps { intervention?: ToolIntervention; result?: { content: string | null; error?: any; state?: any }; + /** Successful tool payload that should surface as warning (e.g. activateTools with only notFound). */ + successVariant?: 'default' | 'warning'; } -const StatusIndicator = memo(({ intervention, result }) => { +const StatusIndicator = memo(({ intervention, result, successVariant }) => { const { t } = useTranslation('chat'); const hasError = !!result?.error; @@ -41,6 +43,8 @@ const StatusIndicator = memo(({ intervention, result }) => icon = ; } else if (isPending) { icon = ; + } else if (hasSuccessResult && !hasError && successVariant === 'warning') { + icon = ; } else if (hasResult) { icon = ; } else { diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx index 2758f56792..c4bf46eefa 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx @@ -1,3 +1,5 @@ +import type { ActivateToolsState } from '@lobechat/builtin-tool-activator'; +import { ActivatorApiName, LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator'; import { getBuiltinInspector } from '@lobechat/builtin-tools/inspectors'; import type { ToolIntervention } from '@lobechat/types'; import { safeParseJSON, safeParsePartialJSON } from '@lobechat/utils'; @@ -38,6 +40,25 @@ const Inspectors = memo( !hasResult && !isPending && !isAborted && !isRejected && !isArgumentsStreaming; const isTitleLoading = isArgumentsStreaming || isToolExecuting; + const activateToolsState = result?.state as ActivateToolsState | undefined; + let statusSuccessVariant: 'warning' | undefined; + if ( + identifier === LobeActivatorIdentifier && + apiName === ActivatorApiName.activateTools && + !isTitleLoading && + !result?.error + ) { + const notFound = activateToolsState?.notFound; + const activated = activateToolsState?.activatedTools; + if ( + Array.isArray(notFound) && + notFound.length > 0 && + (!activated || activated.length === 0) + ) { + statusSuccessVariant = 'warning'; + } + } + // Check for custom inspector renderer const CustomInspector = getBuiltinInspector(identifier, apiName); @@ -46,7 +67,11 @@ const Inspectors = memo( const partialJson = safeParsePartialJSON(argsStr); return ( - + ( return ( - + ( const hasTools = tools && tools.length > 0; const showReasoning = (!!reasoning && reasoning.content?.trim() !== '') || (!reasoning && isReasoning); + const hasContent = !!content && content !== LOADING_FLAT; + const showMessageContent = hasContent || content === LOADING_FLAT || hasTools; const handleRegenerate = useCallback(async () => { await deleteMessage(id); @@ -80,14 +82,16 @@ const ContentBlock = memo( )} - - - + {showMessageContent && ( + + + + )} {showImageItems && ( diff --git a/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx new file mode 100644 index 0000000000..b73b74eb96 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/components/ContentBlocksScroll.tsx @@ -0,0 +1,100 @@ +'use client'; + +import type { UIChatMessage } from '@lobechat/types'; +import { Flexbox, ScrollShadow } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo, type RefObject, useMemo } from 'react'; + +import type { AssistantContentBlock } from '@/types/index'; + +import { resolveAssistantGroupFromMessages } from '../utils/resolveAssistantGroupFromMessages'; +import ContentBlock from './ContentBlock'; + +const styles = createStaticStyles(({ css }) => ({ + scrollTask: css` + max-height: min(50vh, 300px); + `, + scrollWorkflow: css` + max-height: min(40vh, 320px); + `, +})); + +interface ContentBlocksScrollBaseProps { + disableEditing?: boolean; + onScroll?: () => void; + scroll?: boolean; + scrollRef?: RefObject; + variant: 'task' | 'workflow'; +} + +interface ContentBlocksScrollFromBlocks extends ContentBlocksScrollBaseProps { + assistantId: string; + blocks: AssistantContentBlock[]; + messages?: never; +} + +interface ContentBlocksScrollFromMessages extends ContentBlocksScrollBaseProps { + assistantId?: never; + blocks?: never; + messages: UIChatMessage[]; +} + +export type ContentBlocksScrollProps = + | ContentBlocksScrollFromBlocks + | ContentBlocksScrollFromMessages; + +const ContentBlocksScroll = memo((props) => { + const { disableEditing, onScroll, scroll = true, scrollRef, variant } = props; + + const messagesList = 'messages' in props ? props.messages : undefined; + const assistantIdFromProps = 'messages' in props ? undefined : props.assistantId; + const blocksFromProps = 'messages' in props ? undefined : props.blocks; + + const { assistantId, blocks } = useMemo(() => { + if (messagesList !== undefined) { + return resolveAssistantGroupFromMessages(messagesList); + } + return { + assistantId: assistantIdFromProps ?? '', + blocks: blocksFromProps ?? [], + }; + }, [assistantIdFromProps, blocksFromProps, messagesList]); + + const list = ( + + {blocks.map((block) => ( + + ))} + + ); + + const body = variant === 'workflow' ? {list} : list; + + if (!scroll) { + return body; + } + + const scrollClass = variant === 'task' ? styles.scrollTask : styles.scrollWorkflow; + const shadowSize = variant === 'task' ? 8 : 12; + + return ( + } + size={shadowSize} + onScroll={onScroll} + > + {body} + + ); +}); + +ContentBlocksScroll.displayName = 'ContentBlocksScroll'; + +export default ContentBlocksScroll; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx index 1ea327150d..e1659b41a1 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/Group.tsx @@ -8,8 +8,10 @@ import { type AssistantContentBlock } from '@/types/index'; import { messageStateSelectors, useConversationStore } from '../../../store'; import { MessageAggregationContext } from '../../Contexts/MessageAggregationContext'; +import { areWorkflowToolsComplete, getPostToolAnswerSplitIndex } from '../toolDisplayNames'; import { CollapsedMessage } from './CollapsedMessage'; import GroupItem from './GroupItem'; +import WorkflowCollapse from './WorkflowCollapse'; const styles = createStaticStyles(({ css }) => { return { @@ -30,12 +32,133 @@ interface GroupChildrenProps { messageIndex: number; } +interface PartitionedBlocks { + answerBlocks: AssistantContentBlock[]; + /** True while generating if long post-tool answer was moved outside the fold (tool phase UI may show โ€œdoneโ€). */ + postToolTailPromoted: boolean; + workingBlocks: AssistantContentBlock[]; +} + const isEmptyBlock = (block: AssistantContentBlock) => (!block.content || block.content === LOADING_FLAT) && (!block.tools || block.tools.length === 0) && !block.error && !block.reasoning; +/** + * Check if a block contains any tool calls. + */ +const hasTools = (block: AssistantContentBlock): boolean => { + return !!block.tools && block.tools.length > 0; +}; + +const hasSubstantiveContent = (block: AssistantContentBlock): boolean => { + const content = block.content?.trim(); + return !!content && content !== LOADING_FLAT; +}; + +const hasReasoningContent = (block: AssistantContentBlock): boolean => { + return !!block.reasoning?.content?.trim(); +}; + +const isTrailingReasoningCandidate = (block: AssistantContentBlock): boolean => { + return hasReasoningContent(block) && !hasTools(block) && !block.error; +}; + +const splitPostToolBlocks = ( + postBlocks: AssistantContentBlock[], +): Pick => { + const answerBlocks: AssistantContentBlock[] = []; + const workingBlocks: AssistantContentBlock[] = []; + + let index = 0; + while (index < postBlocks.length) { + const block = postBlocks[index]!; + if (!isTrailingReasoningCandidate(block)) break; + + workingBlocks.push({ ...block, content: '' }); + + if (hasSubstantiveContent(block) || (block.imageList?.length ?? 0) > 0) { + answerBlocks.push({ ...block, reasoning: undefined }); + } + + index += 1; + } + + answerBlocks.push(...postBlocks.slice(index)); + + return { answerBlocks, workingBlocks }; +}; + +/** + * Partition blocks into "working phase" and "answer phase". + * + * Working phase: from first block with tools through last block with tools + * (inclusive โ€” interleaved content/reasoning blocks between tool blocks are included). + * + * Answer phase: blocks before the first tool block, plus blocks after the last tool + * (or after detected post-tool โ€œfinal answerโ€ while still generating). + */ +const partitionBlocks = ( + blocks: AssistantContentBlock[], + isGenerating: boolean, +): PartitionedBlocks => { + let lastToolIndex = -1; + for (let i = blocks.length - 1; i >= 0; i--) { + if (hasTools(blocks[i])) { + lastToolIndex = i; + break; + } + } + + if (lastToolIndex === -1) { + return { answerBlocks: blocks, postToolTailPromoted: false, workingBlocks: [] }; + } + + let firstToolIndex = 0; + for (let i = 0; i < blocks.length; i++) { + if (hasTools(blocks[i])) { + firstToolIndex = i; + break; + } + } + + const preBlocks = blocks.slice(0, firstToolIndex); + + if (isGenerating) { + const toolsFlat = blocks.flatMap((b) => b.tools ?? []); + const toolsPhaseComplete = areWorkflowToolsComplete(toolsFlat); + let workingEndExclusive = blocks.length; + let postToolTailPromoted = false; + if (toolsPhaseComplete) { + const split = getPostToolAnswerSplitIndex(blocks, lastToolIndex, toolsPhaseComplete, true); + if (split != null) { + workingEndExclusive = split; + postToolTailPromoted = true; + } + } + + return { + answerBlocks: [...preBlocks, ...blocks.slice(workingEndExclusive)], + postToolTailPromoted, + workingBlocks: blocks.slice(firstToolIndex, workingEndExclusive), + }; + } + + const postBlocks = blocks.slice(lastToolIndex + 1); + const postToolReasoning = splitPostToolBlocks(postBlocks); + const workingBlocks = [ + ...blocks.slice(firstToolIndex, lastToolIndex + 1), + ...postToolReasoning.workingBlocks, + ]; + + return { + answerBlocks: [...preBlocks, ...postToolReasoning.answerBlocks], + postToolTailPromoted: false, + workingBlocks, + }; +}; + const Group = memo( ({ blocks, contentId, disableEditing, messageIndex, id, content }) => { const [isCollapsed, isGenerating] = useConversationStore((s) => [ @@ -44,6 +167,19 @@ const Group = memo( ]); const contextValue = useMemo(() => ({ assistantGroupId: id }), [id]); + const { workingBlocks, answerBlocks, postToolTailPromoted } = useMemo( + () => partitionBlocks(blocks, isGenerating), + [blocks, isGenerating], + ); + + const workflowChromeComplete = !isGenerating || postToolTailPromoted; + + /** First non-placeholder in the answer column (pre-tool + post-tool when finalized). */ + const firstSubstantiveAnswerIndex = useMemo( + () => answerBlocks.findIndex((b) => !isEmptyBlock(b)), + [answerBlocks], + ); + if (isCollapsed) { return ( content && ( @@ -53,10 +189,19 @@ const Group = memo( ) ); } + return ( - {blocks.map((item, index) => { + {workingBlocks.length > 0 && ( + + )} + {answerBlocks.map((item, index) => { if (!isGenerating && isEmptyBlock(item)) return null; return ( @@ -65,9 +210,11 @@ const Group = memo( assistantId={id} contentId={contentId} disableEditing={disableEditing} - isFirstBlock={index === 0} key={id + '.' + item.id} messageIndex={messageIndex} + isFirstBlock={ + firstSubstantiveAnswerIndex >= 0 && index === firstSubstantiveAnswerIndex + } /> ); })} diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx new file mode 100644 index 0000000000..d3e6b21237 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx @@ -0,0 +1,288 @@ +import { type ChatToolPayloadWithResult } from '@lobechat/types'; +import { Accordion, AccordionItem, Block, Flexbox, Icon, Text } from '@lobehub/ui'; +import { cssVar } from 'antd-style'; +import { Check, X } from 'lucide-react'; +import { AnimatePresence, m as motion } from 'motion/react'; +import { type Key, memo, useEffect, useMemo, useRef, useState } from 'react'; + +import NeuralNetworkLoading from '@/components/NeuralNetworkLoading'; +import { useAutoScroll } from '@/hooks/useAutoScroll'; +import { shinyTextStyles } from '@/styles'; +import { type AssistantContentBlock } from '@/types/index'; + +import { messageStateSelectors, useConversationStore } from '../../../store'; +import { + TIME_MS_PER_SECOND, + WORKFLOW_EXPANDED_SCROLL_THRESHOLD_PX, + WORKFLOW_HEADLINE_DEBOUNCE_MS, + WORKFLOW_PROSE_IDLE_COMMIT_MS, + WORKFLOW_PROSE_QUICK_COMMIT_MS, + WORKFLOW_STREAMING_TITLE_MIN_HEIGHT_PX, + WORKFLOW_WORKING_ELAPSED_SHOW_AFTER_MS, +} from '../constants'; +import { + areWorkflowToolsComplete, + formatReasoningDuration, + getWorkflowStreamingHeadlineParts, + getWorkflowSummaryText, + hasToolError, + shapeProseForWorkflowHeadline, +} from '../toolDisplayNames'; +import WorkflowExpandedList from './WorkflowExpandedList'; + +interface WorkflowCollapseProps { + /** Assistant group message id (for generation state) */ + assistantMessageId: string; + blocks: AssistantContentBlock[]; + disableEditing?: boolean; + workflowChromeComplete?: boolean; +} + +const collectTools = (blocks: AssistantContentBlock[]): ChatToolPayloadWithResult[] => { + return blocks.flatMap((b) => b.tools ?? []); +}; + +const useDebouncedHeadline = (raw: string, allComplete: boolean) => { + const [out, setOut] = useState(raw); + const prevCompleteRef = useRef(allComplete); + + useEffect(() => { + const wasComplete = prevCompleteRef.current; + prevCompleteRef.current = allComplete; + const streaming = !allComplete; + + if (!streaming) { + setOut(raw); + return; + } + if (wasComplete) { + setOut(raw); + return; + } + const id = window.setTimeout(() => setOut(raw), WORKFLOW_HEADLINE_DEBOUNCE_MS); + return () => window.clearTimeout(id); + }, [allComplete, raw]); + + return !allComplete ? out : raw; +}; + +const useCommittedProseHeadline = (proseSource: string, streaming: boolean) => { + const [committed, setCommitted] = useState(''); + + useEffect(() => { + if (!streaming) { + setCommitted(''); + return; + } + if (!proseSource.trim()) { + setCommitted(''); + return; + } + const shaped = shapeProseForWorkflowHeadline(proseSource); + if (!shaped) { + setCommitted(''); + return; + } + const quick = /[ใ€‚๏ผ๏ผŸ.!?]\s*$/.test(shaped); + const delay = quick ? WORKFLOW_PROSE_QUICK_COMMIT_MS : WORKFLOW_PROSE_IDLE_COMMIT_MS; + const id = window.setTimeout(() => setCommitted(shaped), delay); + return () => window.clearTimeout(id); + }, [proseSource, streaming]); + + return committed; +}; + +const WorkflowCollapse = memo( + ({ assistantMessageId, blocks, disableEditing, workflowChromeComplete = false }) => { + const allTools = useMemo(() => collectTools(blocks), [blocks]); + const toolsPhaseComplete = areWorkflowToolsComplete(allTools); + const isGenerating = useConversationStore( + messageStateSelectors.isMessageGenerating(assistantMessageId), + ); + + const allComplete = toolsPhaseComplete && (workflowChromeComplete || !isGenerating); + const summaryText = useMemo(() => getWorkflowSummaryText(blocks), [blocks]); + const errorPresent = hasToolError(allTools); + + /** Sum of per-round model output duration (not reasoning-only); see ModelPerformance.duration */ + const totalWorkflowMs = useMemo( + () => blocks.reduce((sum, b) => sum + (b.performance?.duration ?? 0), 0), + [blocks], + ); + const durationText = totalWorkflowMs > 0 ? formatReasoningDuration(totalWorkflowMs) : undefined; + + const [expanded, setExpanded] = useState(false); + const userOpenedRef = useRef(false); + const prevCompleteRef = useRef(allComplete); + + useEffect(() => { + const wasComplete = prevCompleteRef.current; + prevCompleteRef.current = allComplete; + + if (!allComplete && wasComplete) { + userOpenedRef.current = false; + } + + if (allComplete && !wasComplete && !userOpenedRef.current && allTools.length > 0) { + setExpanded(false); + } + }, [allComplete, allTools.length]); + + const streaming = !allComplete; + const isExpanded = expanded; + + const { explicitStep, fallbackTool, proseSource } = useMemo( + () => getWorkflowStreamingHeadlineParts(blocks, allTools), + [blocks, allTools], + ); + const committedProse = useCommittedProseHeadline(proseSource, streaming); + + const streamingHeadlineRaw = useMemo(() => { + if (explicitStep) return explicitStep; + if (committedProse) return committedProse; + if (fallbackTool) return fallbackTool; + return ''; + }, [committedProse, explicitStep, fallbackTool]); + const streamingHeadline = useDebouncedHeadline(streamingHeadlineRaw, allComplete); + + const [workingElapsedSeconds, setWorkingElapsedSeconds] = useState(0); + + useEffect(() => { + if (!streaming) { + setWorkingElapsedSeconds(0); + return; + } + + const start = Date.now(); + const tick = () => { + setWorkingElapsedSeconds(Math.floor((Date.now() - start) / 1000)); + }; + + tick(); + const interval = setInterval(tick, 1000); + + return () => clearInterval(interval); + }, [streaming]); + + const showWorkingElapsed = + workingElapsedSeconds >= WORKFLOW_WORKING_ELAPSED_SHOW_AFTER_MS / TIME_MS_PER_SECOND; + + const handleExpandedChange = (keys: Key[]) => { + const nowExpanded = keys.includes('workflow'); + setExpanded(nowExpanded); + if (nowExpanded) userOpenedRef.current = true; + }; + const constrained = streaming && expanded; + + const { ref: scrollRef, handleScroll: handleAutoScroll } = useAutoScroll({ + deps: [allTools.length], + enabled: constrained, + threshold: WORKFLOW_EXPANDED_SCROLL_THRESHOLD_PX, + }); + + const statusIcon = streaming ? ( + + ) : errorPresent ? ( + + ) : ( + + ); + + const title = ( + + + {statusIcon} + + {streaming ? ( + +
+ + + + {streamingHeadline || 'Working...'} + + + +
+ {showWorkingElapsed && ( + + ({workingElapsedSeconds}s) + + )} +
+ ) : ( + + + {summaryText} + + {durationText && ( + + {durationText} + + )} + + )} +
+ ); + + return ( + + + + + + ); + }, +); + +WorkflowCollapse.displayName = 'WorkflowCollapse'; + +export default WorkflowCollapse; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx new file mode 100644 index 0000000000..9d8dd69087 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowExpandedList.tsx @@ -0,0 +1,32 @@ +import { memo, type RefObject } from 'react'; + +import type { AssistantContentBlock } from '@/types/index'; + +import ContentBlocksScroll from './ContentBlocksScroll'; + +interface WorkflowExpandedListProps { + assistantId: string; + blocks: AssistantContentBlock[]; + constrained?: boolean; + disableEditing?: boolean; + onScroll?: () => void; + scrollRef?: RefObject; +} + +const WorkflowExpandedList = memo( + ({ assistantId, blocks, constrained, disableEditing, onScroll, scrollRef }) => ( + + ), +); + +WorkflowExpandedList.displayName = 'WorkflowExpandedList'; + +export default WorkflowExpandedList; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowToolDetail.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowToolDetail.tsx new file mode 100644 index 0000000000..dc186ff901 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowToolDetail.tsx @@ -0,0 +1,48 @@ +import { Highlighter } from '@lobehub/ui'; +import { AnimatePresence, m as motion } from 'motion/react'; +import { memo, useMemo } from 'react'; + +interface WorkflowToolDetailProps { + content: string; + open: boolean; +} + +const WorkflowToolDetail = memo(({ content, open }) => { + const language = useMemo(() => { + const trimmed = content.trimStart(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 'json'; + return 'plaintext'; + }, [content]); + + const formatted = useMemo(() => { + if (language !== 'json') return content; + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch { + return content; + } + }, [content, language]); + + return ( + + {open && ( + + + {formatted} + + + )} + + ); +}); + +WorkflowToolDetail.displayName = 'WorkflowToolDetail'; + +export default WorkflowToolDetail; diff --git a/src/features/Conversation/Messages/AssistantGroup/constants.ts b/src/features/Conversation/Messages/AssistantGroup/constants.ts new file mode 100644 index 0000000000..4a642c8ce4 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/constants.ts @@ -0,0 +1,207 @@ +/** + * Assistant group / workflow UI โ€” tunable limits, timing, heuristics, and apiName display labels. + * Centralizes magic numbers used by Group, WorkflowCollapse, and toolDisplayNames helpers. + */ + +// โ”€โ”€โ”€ Workflow collapse (WorkflowCollapse) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Elapsed timer in the working header only appears after this many ms (aligns with ContentLoading). */ +export const WORKFLOW_WORKING_ELAPSED_SHOW_AFTER_MS = 2100; + +/** Debounce when B/C headline text changes to avoid streaming arg chunks thrashing the title animation. */ +export const WORKFLOW_HEADLINE_DEBOUNCE_MS = 320; + +/** After prose forms a complete sentence (trailing CJK/Latin punct), commit headline after this delay. */ +export const WORKFLOW_PROSE_QUICK_COMMIT_MS = 280; + +/** Partial prose without sentence end: commit headline after this idle delay. */ +export const WORKFLOW_PROSE_IDLE_COMMIT_MS = 680; + +/** Min height (px) for the streaming title row to reduce layout shift during motion. */ +export const WORKFLOW_STREAMING_TITLE_MIN_HEIGHT_PX = 22; + +/** Pixels from bottom of scroll port: auto-scroll in expanded workflow list stays active within this margin. */ +export const WORKFLOW_EXPANDED_SCROLL_THRESHOLD_PX = 120; + +// โ”€โ”€โ”€ One-line prose headline shaping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Hard cap for shaped workflow title before word-boundary ellipsis. */ +export const WORKFLOW_PROSE_HEADLINE_MAX_CHARS = 100; + +/** Ignore very short fragments; also minimum for โ€œvalidโ€ sentence after cut. */ +export const WORKFLOW_PROSE_MIN_CHARS = 8; + +/** Minimum trimmed `content` length when picking a block for live headline source. */ +export const WORKFLOW_PROSE_SOURCE_MIN_CHARS = 8; + +/** + * List-marker junk filter: reject single-line bodies like "- a" from being a headline + * (max word chars after list marker). + */ +export const WORKFLOW_PROSE_LIST_MARKER_MAX_TAIL_WORD_CHARS = 3; + +/** When truncating at space, require last space to be at least this fraction of max (avoid tiny cuts). */ +export const WORKFLOW_TRUNCATE_WORD_BOUNDARY_MIN_RATIO = 0.55; + +/** Strip markdown headings: match ATX `#` up to this many levels. */ +export const WORKFLOW_MARKDOWN_HEADING_MAX_LEVEL = 6; + +// โ”€โ”€โ”€ Tool argument / step lines (headline B/C and summaries) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** First string tool argument preview: show at most this many chars before "โ€ฆ". */ +export const TOOL_FIRST_DETAIL_MAX_CHARS = 80; + +/** Tool step / arg combined headline: soft cap for readable one-liner. */ +export const TOOL_HEADLINE_DETAIL_MAX_CHARS = 120; + +/** Slice length before appending ellipsis when over TOOL_HEADLINE_DETAIL_MAX_CHARS (room for "..."). */ +export const TOOL_HEADLINE_DETAIL_TRUNCATE_LEN = 117; + +/** Suffix when truncating tool strings. */ +export const TOOL_HEADLINE_TRUNCATION_SUFFIX = '...'; + +// โ”€โ”€โ”€ Post-tool โ€œfinal answerโ€ block promotion (Group partition) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Sum of heuristic scores at or above this moves blocks after last tool into answer column while generating. */ +export const POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD = 3; + +/** Add this score when compacted prose length โ‰ฅ this (long answer signal). */ +export const POST_TOOL_ANSWER_LENGTH_LONG_SCORE = 2; + +/** Lower bound (chars) for POST_TOOL_ANSWER_LENGTH_LONG_SCORE. */ +export const POST_TOOL_ANSWER_LENGTH_LONG_MIN_CHARS = 180; + +/** Add this score when length โˆˆ [medium min, long min). */ +export const POST_TOOL_ANSWER_MEDIUM_TEXT_SCORE = 1; + +/** Lower bound (chars) for medium-length contribution. */ +export const POST_TOOL_ANSWER_LENGTH_MEDIUM_MIN_CHARS = 100; + +/** Blank-line paragraphing: strong signal for structured deliverable. */ +export const POST_TOOL_ANSWER_DOUBLE_NEWLINE_SCORE = 2; + +/** Without \\n\\n, treat many non-empty lines as paragraphing when count โ‰ฅ this. */ +export const POST_TOOL_ANSWER_MULTI_LINE_SCORE = 2; + +/** Minimum trimmed lines (with at least one non-empty) to count as multi-line body. */ +export const POST_TOOL_ANSWER_MULTI_LINE_MIN_COUNT = 3; + +/** Markdown heading or list at line start: structured deliverable. */ +export const POST_TOOL_ANSWER_MARKDOWN_STRUCTURE_SCORE = 2; + +/** Add one point when sentence-ending punctuation count โ‰ฅ this (compact text). */ +export const POST_TOOL_ANSWER_PUNCT_MIN_COUNT = 3; + +export const POST_TOOL_ANSWER_PUNCT_SCORE = 1; + +// โ”€โ”€โ”€ Time formatting (workflow summary / reasoning suffix) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Seconds per minute when formatting durations like "2m 30s". */ +export const DURATION_SECONDS_PER_MINUTE = 60; + +/** Duration inputs are in milliseconds; convert to whole seconds for display. */ +export const TIME_MS_PER_SECOND = 1000; + +// โ”€โ”€โ”€ apiName โ†’ past-tense human-readable label (workflow summary & headlines) โ”€ + +/** Past-tense labels for built-in / known tool api names. Unknown api names use title-cased fallback. */ +export const TOOL_API_DISPLAY_NAMES: Record = { + // Web browsing + crawlMultiPages: 'Crawled pages', + crawlSinglePage: 'Crawled a page', + search: 'Searched the web', + + // Knowledge base + readKnowledge: 'Read knowledge', + searchKnowledgeBase: 'Searched knowledge base', + + // Notebook + createDocument: 'Created a document', + deleteDocument: 'Deleted a document', + getDocument: 'Read a document', + updateDocument: 'Updated a document', + + // Agent documents + copyDocument: 'Copied a document', + editDocument: 'Edited a document', + listDocuments: 'Listed documents', + readDocument: 'Read a document', + readDocumentByFilename: 'Read a document', + removeDocument: 'Removed a document', + renameDocument: 'Renamed a document', + upsertDocumentByFilename: 'Updated a document', + updateLoadRule: 'Updated load rule', + + // Calculator + calculate: 'Calculated', + evaluate: 'Evaluated expression', + solve: 'Solved equation', + execute: 'Executed calculation', + + // Local system + editLocalFile: 'Edited a file', + globLocalFiles: 'Searched files', + grepContent: 'Searched content', + killCommand: 'Stopped a command', + listLocalFiles: 'Listed files', + moveLocalFiles: 'Moved files', + readLocalFile: 'Read a file', + renameLocalFile: 'Renamed a file', + runCommand: 'Ran a command', + searchLocalFiles: 'Searched files', + writeLocalFile: 'Wrote a file', + getCommandOutput: 'Read command output', + + // Cloud sandbox + executeCode: 'Executed code', + + // GTD + createPlan: 'Created a plan', + createTodos: 'Created todos', + updatePlan: 'Updated plan', + updateTodos: 'Updated todos', + clearTodos: 'Cleared todos', + execTask: 'Executed a task', + execTasks: 'Executed tasks', + + // Memory + addActivityMemory: 'Saved memory', + addContextMemory: 'Saved memory', + addExperienceMemory: 'Saved memory', + addIdentityMemory: 'Saved memory', + addPreferenceMemory: 'Saved memory', + removeIdentityMemory: 'Removed memory', + searchUserMemory: 'Searched memory', + updateIdentityMemory: 'Updated memory', + + // Agent management + callAgent: 'Called an agent', + createAgent: 'Created an agent', + deleteAgent: 'Deleted an agent', + searchAgent: 'Searched agents', + updateAgent: 'Updated an agent', + + // Page agent + editTitle: 'Edited title', + getPageContent: 'Read page content', + initPage: 'Initialized page', + modifyNodes: 'Modified page', + replaceText: 'Replaced text', + + // Skills + activateSkill: 'Activated a skill', + activateTools: 'Activated tools', + execScript: 'Executed a script', + + // Skill store + importFromMarket: 'Imported from market', + importSkill: 'Imported a skill', + searchSkill: 'Searched skills', + + // Misc + finishOnboarding: 'Finished onboarding', + getOnboardingState: 'Checked onboarding state', + getTopicContext: 'Read topic context', + listOnlineDevices: 'Listed devices', + activateDevice: 'Activated device', +}; diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts new file mode 100644 index 0000000000..dff81c9688 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { type AssistantContentBlock } from '@/types/index'; + +import { POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD } from './constants'; +import { + getPostToolAnswerSplitIndex, + scorePostToolBlockAsFinalAnswer, + shapeProseForWorkflowHeadline, +} from './toolDisplayNames'; + +const blk = (p: Partial & { id: string }): AssistantContentBlock => + ({ content: '', ...p }) as AssistantContentBlock; + +describe('shapeProseForWorkflowHeadline', () => { + it('does not split on dot inside Node.js in CJK prose', () => { + const s = + 'ๆˆ‘ๆฅๅธฎๆ‚จๆœ็ดข Node.js 24 ็š„ๅ‘ๅธƒ่ฏดๆ˜Žๅนถๆ’ฐๅ†™ไธ€ไปฝๅ…จ้ข็š„ๆŠ€ๆœฏๆ€ป็ป“ใ€‚้ฆ–ๅ…ˆ๏ผŒๆˆ‘้œ€่ฆๆฟ€ๆดปๅฟ…่ฆ็š„ๅทฅๅ…ทๆฅ่ฟ›่กŒๆœ็ดขๅ’Œๆ–‡ไปถๆ“ไฝœใ€‚'; + const out = shapeProseForWorkflowHeadline(s); + expect(out).toContain('Node.js 24'); + expect(out).toContain('ๆŠ€ๆœฏๆ€ป็ป“'); + expect(out).not.toMatch(/^ๆˆ‘ๆฅๅธฎๆ‚จๆœ็ดข Node\.?\s*$/i); + }); + + it('uses Latin sentence dot when no CJK', () => { + const s = 'Search Node.js 24 release notes. Then crawl docs.'; + const out = shapeProseForWorkflowHeadline(s); + expect(out).toContain('Node.js 24'); + expect(out).toContain('release notes'); + expect(out).not.toContain('Then crawl'); + }); +}); + +describe('post-tool final answer split', () => { + it('returns split index for long structured prose-only block after last tool', () => { + const long = + 'Direct summary - Node.js 24 (released May 6, 2025) is a major platform update that upgrades V8 to a newer track, ships notable HTTP and fetch-related changes, and introduces practical migration items for native addons and tooling.\n\n## Checklist\n\n- Rebuild native modules'; + const blocks = [ + blk({ id: '0', content: 'intro', tools: [{ apiName: 'search', id: 't1' } as any] }), + blk({ id: '1', content: long }), + ]; + const ix = getPostToolAnswerSplitIndex(blocks, 0, true, true); + expect(ix).toBe(1); + }); + + it('does not split short step line after tools', () => { + const blocks = [ + blk({ id: '0', content: 'x', tools: [{ apiName: 'search', id: 't1' } as any] }), + blk({ id: '1', content: '็Žฐๅœจๆˆ‘ๆฅๆœ็ดข่ต„ๆ–™ใ€‚' }), + ]; + expect(scorePostToolBlockAsFinalAnswer(blocks[1]!)).toBeLessThan( + POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD, + ); + expect(getPostToolAnswerSplitIndex(blocks, 0, true, true)).toBeNull(); + }); +}); diff --git a/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts new file mode 100644 index 0000000000..8f35701589 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/toolDisplayNames.ts @@ -0,0 +1,324 @@ +import { type ChatToolPayloadWithResult } from '@lobechat/types'; + +import { LOADING_FLAT } from '@/const/message'; +import { type AssistantContentBlock } from '@/types/index'; + +import { + DURATION_SECONDS_PER_MINUTE, + POST_TOOL_ANSWER_DOUBLE_NEWLINE_SCORE, + POST_TOOL_ANSWER_LENGTH_LONG_MIN_CHARS, + POST_TOOL_ANSWER_LENGTH_LONG_SCORE, + POST_TOOL_ANSWER_LENGTH_MEDIUM_MIN_CHARS, + POST_TOOL_ANSWER_MARKDOWN_STRUCTURE_SCORE, + POST_TOOL_ANSWER_MEDIUM_TEXT_SCORE, + POST_TOOL_ANSWER_MULTI_LINE_MIN_COUNT, + POST_TOOL_ANSWER_MULTI_LINE_SCORE, + POST_TOOL_ANSWER_PUNCT_MIN_COUNT, + POST_TOOL_ANSWER_PUNCT_SCORE, + POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD, + TIME_MS_PER_SECOND, + TOOL_API_DISPLAY_NAMES, + TOOL_FIRST_DETAIL_MAX_CHARS, + TOOL_HEADLINE_DETAIL_MAX_CHARS, + TOOL_HEADLINE_DETAIL_TRUNCATE_LEN, + TOOL_HEADLINE_TRUNCATION_SUFFIX, + WORKFLOW_MARKDOWN_HEADING_MAX_LEVEL, + WORKFLOW_PROSE_HEADLINE_MAX_CHARS, + WORKFLOW_PROSE_LIST_MARKER_MAX_TAIL_WORD_CHARS, + WORKFLOW_PROSE_MIN_CHARS, + WORKFLOW_PROSE_SOURCE_MIN_CHARS, + WORKFLOW_TRUNCATE_WORD_BOUNDARY_MIN_RATIO, +} from './constants'; + +export const areWorkflowToolsComplete = (tools: ChatToolPayloadWithResult[]): boolean => { + const collapsible = tools.filter((t) => t.intervention?.status !== 'pending'); + if (collapsible.length === 0) return false; + return collapsible.every((t) => t.result != null && t.result.content !== LOADING_FLAT); +}; + +/** Heuristic: prose-only block after last tool looks like a long deliverable (not a one-line step). */ +export const scorePostToolBlockAsFinalAnswer = (block: AssistantContentBlock): number => { + if (block.tools && block.tools.length > 0) return 0; + const raw = (block.content ?? '').trim(); + if (!raw || raw === LOADING_FLAT) return 0; + + let score = 0; + const compact = raw.replaceAll(/\s+/g, ' '); + if (compact.length >= POST_TOOL_ANSWER_LENGTH_LONG_MIN_CHARS) + score += POST_TOOL_ANSWER_LENGTH_LONG_SCORE; + else if (compact.length >= POST_TOOL_ANSWER_LENGTH_MEDIUM_MIN_CHARS) + score += POST_TOOL_ANSWER_MEDIUM_TEXT_SCORE; + + if (raw.includes('\n\n')) score += POST_TOOL_ANSWER_DOUBLE_NEWLINE_SCORE; + else if (raw.split('\n').filter((l) => l.trim()).length >= POST_TOOL_ANSWER_MULTI_LINE_MIN_COUNT) + score += POST_TOOL_ANSWER_MULTI_LINE_SCORE; + + if ( + new RegExp(`^#{1,${WORKFLOW_MARKDOWN_HEADING_MAX_LEVEL}}\\s`, 'm').test(raw) || + /^\s*[-*]\s+\S/m.test(raw) + ) + score += POST_TOOL_ANSWER_MARKDOWN_STRUCTURE_SCORE; + + const punctCount = (compact.match(/[ใ€‚๏ผ๏ผŸ.!?]/g) ?? []).length; + if (punctCount >= POST_TOOL_ANSWER_PUNCT_MIN_COUNT) score += POST_TOOL_ANSWER_PUNCT_SCORE; + + return score; +}; + +/** + * While generating, first index at or after {@param lastToolIndex} whose prose-only block scores + * as final-answer-like. Tail from here stays out of the workflow fold. Returns null if tooling + * reappears or nothing qualifies. + */ +export const getPostToolAnswerSplitIndex = ( + blocks: AssistantContentBlock[], + lastToolIndex: number, + toolsPhaseComplete: boolean, + isGenerating: boolean, +): number | null => { + if (!isGenerating || !toolsPhaseComplete || lastToolIndex < 0) return null; + if (lastToolIndex >= blocks.length - 1) return null; + + for (let i = lastToolIndex + 1; i < blocks.length; i++) { + const b = blocks[i]!; + if (b.tools && b.tools.length > 0) return null; + if (scorePostToolBlockAsFinalAnswer(b) >= POST_TOOL_FINAL_ANSWER_SCORE_THRESHOLD) return i; + } + return null; +}; + +const toTitleCase = (apiName: string): string => { + return apiName + .replaceAll(/([A-Z])/g, ' $1') + .replace(/^./, (s) => s.toUpperCase()) + .trim(); +}; + +export const getToolDisplayName = (apiName: string): string => { + return TOOL_API_DISPLAY_NAMES[apiName] || toTitleCase(apiName); +}; + +export const getToolSummaryText = (tools: ChatToolPayloadWithResult[]): string => { + const groups = new Map(); + for (const tool of tools) { + groups.set(tool.apiName, (groups.get(tool.apiName) || 0) + 1); + } + + const parts: string[] = []; + for (const [apiName, count] of groups) { + const name = getToolDisplayName(apiName); + if (count > 1) { + parts.push(`${name} (${count})`); + } else { + parts.push(name); + } + } + + return parts.join(', '); +}; + +export const hasToolError = (tools: ChatToolPayloadWithResult[]): boolean => { + return tools.some((t) => t.result?.error); +}; + +export const getToolFirstDetail = (tool: ChatToolPayloadWithResult): string => { + try { + const args = JSON.parse(tool.arguments || '{}'); + const values = Object.values(args); + for (const val of values) { + if (typeof val === 'string' && val.trim()) { + return val.length > TOOL_FIRST_DETAIL_MAX_CHARS + ? val.slice(0, TOOL_FIRST_DETAIL_MAX_CHARS) + TOOL_HEADLINE_TRUNCATION_SUFFIX + : val; + } + } + } catch { + // arguments still streaming or invalid + } + return ''; +}; + +/** Optional progress line from tool-runtime state (pluginState โ†’ result.state) or metadata */ +interface WorkflowHeadlinePayload { + metadata?: { workflow?: { stepMessage?: string } }; + state?: { workflowHeadline?: { stepMessage?: string } }; +} + +const getResultStepMessage = (tool: ChatToolPayloadWithResult): string => { + const r = tool.result as WorkflowHeadlinePayload | null | undefined; + if (!r) return ''; + const fromState = r.state?.workflowHeadline?.stepMessage?.trim(); + if (fromState) return fromState; + return r.metadata?.workflow?.stepMessage?.trim() ?? ''; +}; + +/** B โ€” runtime stepMessage only (no args fallback). */ +export const getExplicitStepHeadlineLine = (tool: ChatToolPayloadWithResult): string => { + const step = getResultStepMessage(tool).trim(); + if (!step) return ''; + const label = getToolDisplayName(tool.apiName); + const short = + step.length > TOOL_HEADLINE_DETAIL_MAX_CHARS + ? step.slice(0, TOOL_HEADLINE_DETAIL_TRUNCATE_LEN) + TOOL_HEADLINE_TRUNCATION_SUFFIX + : step; + return `${label}: ${short}`; +}; + +/** C โ€” tool label + first string arg (no explicit step). */ +export const getToolFallbackHeadlineLine = (tool: ChatToolPayloadWithResult): string => { + const label = getToolDisplayName(tool.apiName); + const fromArgs = getToolFirstDetail(tool).trim(); + if (fromArgs) { + const short = + fromArgs.length > TOOL_HEADLINE_DETAIL_MAX_CHARS + ? fromArgs.slice(0, TOOL_HEADLINE_DETAIL_TRUNCATE_LEN) + TOOL_HEADLINE_TRUNCATION_SUFFIX + : fromArgs; + return `${label}: ${short}`; + } + return label; +}; + +/** + * One-line status for a single tool: label + optional step / first string arg. + * Prefer explicit stepMessage when backends populate workflowHeadline / metadata.workflow. + */ +export const getToolStepHeadlineLine = (tool: ChatToolPayloadWithResult): string => { + const explicit = getExplicitStepHeadlineLine(tool); + if (explicit) return explicit; + return getToolFallbackHeadlineLine(tool); +}; + +const truncateDisplayAtWord = (s: string, max: number): string => { + if (s.length <= max) return s; + const slice = s.slice(0, max); + const lastSpace = slice.lastIndexOf(' '); + if (lastSpace > max * WORKFLOW_TRUNCATE_WORD_BOUNDARY_MIN_RATIO) + return `${slice.slice(0, lastSpace)}${TOOL_HEADLINE_TRUNCATION_SUFFIX}`; + return `${slice}${TOOL_HEADLINE_TRUNCATION_SUFFIX}`; +}; + +/** Han / full-width CJK punctuation โ€” if present, prefer ใ€‚๏ผ๏ผŸ only (ASCII . is not a sentence end). */ +/** CJK Han block โ€” prefer ใ€‚๏ผ๏ผŸ sentence ends (see constants module comment). */ +const hasCjkScript = (s: string): boolean => /[\u4E00-\u9FFF]/.test(s); + +const firstSentenceEndCjk = (s: string): number => { + const i = s.search(/[ใ€‚๏ผ๏ผŸ]/); + return i; +}; + +const isAlphanum = (c: string) => /[a-z\d]/i.test(c); + +/** Latin-heavy: treat .!? as ends but skip dots inside tokens (Node.js, 3.14, โ€ฆ). */ +const firstSentenceEndLatin = (s: string): number => { + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (ch === 'ใ€‚' || ch === '๏ผ' || ch === '๏ผŸ') return i; + if (ch === '!' || ch === '?') return i; + if (ch === '.') { + const prev = s[i - 1] ?? ''; + const next = s[i + 1] ?? ''; + if (isAlphanum(prev) && isAlphanum(next)) continue; + if (/\d/.test(prev) && /\d/.test(next)) continue; + return i; + } + } + return -1; +}; + +const stripLightMarkdownForHeadline = (md: string): string => { + let s = md; + s = s.replaceAll(/```[\s\S]*?```/g, ' '); + s = s.replaceAll(/`([^`]+)`/g, '$1'); + s = s.replaceAll(/\*\*?|__/g, ''); + s = s.replaceAll(new RegExp(`^#{1,${WORKFLOW_MARKDOWN_HEADING_MAX_LEVEL}}\\s+`, 'gm'), ''); + s = s.replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + return s; +}; + +/** + * Deterministic one-line snippet from streamed assistant prose (A path). + * Prefers a full sentence when punctuation exists; otherwise trims to max width. + */ +export const shapeProseForWorkflowHeadline = (source: string): string => { + let s = stripLightMarkdownForHeadline(source); + s = s.replaceAll(/\s+/g, ' ').trim(); + if (s.length < WORKFLOW_PROSE_MIN_CHARS) return ''; + if (new RegExp(`^[-*+]\\s*\\w{0,${WORKFLOW_PROSE_LIST_MARKER_MAX_TAIL_WORD_CHARS}}$`).test(s)) + return ''; + + const endIdx = hasCjkScript(s) ? firstSentenceEndCjk(s) : firstSentenceEndLatin(s); + if (endIdx >= 0) { + const sentence = s.slice(0, endIdx + 1).trim(); + if (sentence.length >= WORKFLOW_PROSE_MIN_CHARS) + return truncateDisplayAtWord(sentence, WORKFLOW_PROSE_HEADLINE_MAX_CHARS); + } + + return truncateDisplayAtWord(s, WORKFLOW_PROSE_HEADLINE_MAX_CHARS); +}; + +/** Raw assistant `content` from the latest block that qualifies (scan from end). */ +export const extractLatestProseHeadlineSource = (blocks: AssistantContentBlock[]): string => { + for (let i = blocks.length - 1; i >= 0; i--) { + const c = blocks[i]?.content?.trim() ?? ''; + if (!c || c === LOADING_FLAT) continue; + if (c.length < WORKFLOW_PROSE_SOURCE_MIN_CHARS) continue; + return c; + } + return ''; +}; + +export interface WorkflowStreamingHeadlineParts { + explicitStep: string; + fallbackTool: string; + proseSource: string; +} + +/** Split B / raw A source / C for streaming headline composition (A commits in UI with idle/sentence rules). */ +export const getWorkflowStreamingHeadlineParts = ( + blocks: AssistantContentBlock[], + tools: ChatToolPayloadWithResult[], +): WorkflowStreamingHeadlineParts => { + const last = tools.at(-1); + return { + explicitStep: last ? getExplicitStepHeadlineLine(last) : '', + fallbackTool: last ? getToolFallbackHeadlineLine(last) : '', + proseSource: extractLatestProseHeadlineSource(blocks), + }; +}; + +export const formatReasoningDuration = (ms: number): string => { + const totalSeconds = Math.round(ms / TIME_MS_PER_SECOND); + if (totalSeconds < DURATION_SECONDS_PER_MINUTE) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / DURATION_SECONDS_PER_MINUTE); + const seconds = totalSeconds % DURATION_SECONDS_PER_MINUTE; + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +}; + +export const getWorkflowSummaryText = (blocks: AssistantContentBlock[]): string => { + const tools = blocks.flatMap((b) => b.tools ?? []); + + const groups = new Map(); + for (const tool of tools) { + const existing = groups.get(tool.apiName) || { count: 0, errorCount: 0 }; + existing.count++; + if (tool.result?.error) existing.errorCount++; + groups.set(tool.apiName, existing); + } + + const toolParts: string[] = []; + for (const [apiName, { count, errorCount }] of groups) { + let part = getToolDisplayName(apiName); + if (count > 1) part += ` (${count})`; + if (errorCount > 0) part += ' (failed)'; + toolParts.push(part); + } + + let result = toolParts.join(', '); + + const totalReasoningMs = blocks.reduce((sum, b) => sum + (b.reasoning?.duration ?? 0), 0); + if (totalReasoningMs > 0) { + result += ` ยท Thought for ${formatReasoningDuration(totalReasoningMs)}`; + } + + return result; +}; diff --git a/src/features/Conversation/Messages/AssistantGroup/utils/resolveAssistantGroupFromMessages.ts b/src/features/Conversation/Messages/AssistantGroup/utils/resolveAssistantGroupFromMessages.ts new file mode 100644 index 0000000000..061ea283c0 --- /dev/null +++ b/src/features/Conversation/Messages/AssistantGroup/utils/resolveAssistantGroupFromMessages.ts @@ -0,0 +1,49 @@ +import type { AssistantContentBlock, UIChatMessage } from '@lobechat/types'; + +export interface ResolvedAssistantGroup { + assistantId: string; + blocks: AssistantContentBlock[]; + instruction?: string; +} + +/** + * Resolve assistant group blocks from a thread/conversation message list. + * Prefers `role === 'assistantGroup'` children; falls back to a plain `assistant` message as a single block. + */ +export function resolveAssistantGroupFromMessages( + messages: UIChatMessage[] | undefined | null, +): ResolvedAssistantGroup { + if (!messages || messages.length === 0) { + return { assistantId: '', blocks: [] }; + } + + const assistantGroupMessage = messages.find((item) => item.role === 'assistantGroup'); + const userMessage = messages.find((item) => item.role === 'user'); + + if (assistantGroupMessage) { + return { + assistantId: assistantGroupMessage.id ?? '', + blocks: assistantGroupMessage.children ?? [], + instruction: userMessage?.content, + }; + } + + const assistantMessage = messages.find((item) => item.role === 'assistant'); + if (assistantMessage) { + const block: AssistantContentBlock = { + content: assistantMessage.content || '', + id: assistantMessage.id, + }; + + if (assistantMessage.error) block.error = assistantMessage.error; + if (assistantMessage.reasoning) block.reasoning = assistantMessage.reasoning; + + return { + assistantId: assistantMessage.id ?? '', + blocks: [block], + instruction: userMessage?.content, + }; + } + + return { assistantId: '', blocks: [] }; +} diff --git a/src/features/Conversation/Messages/Tasks/shared/TaskMessages.tsx b/src/features/Conversation/Messages/Tasks/shared/TaskMessages.tsx index 54d0d754df..bd43ed5992 100644 --- a/src/features/Conversation/Messages/Tasks/shared/TaskMessages.tsx +++ b/src/features/Conversation/Messages/Tasks/shared/TaskMessages.tsx @@ -1,19 +1,9 @@ 'use client'; import { type AssistantContentBlock, type UIChatMessage } from '@lobechat/types'; -import { - Accordion, - AccordionItem, - Block, - Flexbox, - Icon, - Markdown, - ScrollShadow, - Text, -} from '@lobehub/ui'; +import { Accordion, AccordionItem, Block, Flexbox, Icon, Markdown, Text } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; import { ScrollText, Workflow } from 'lucide-react'; -import { type RefObject } from 'react'; import { memo, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,14 +13,13 @@ import { useUserStore } from '@/store/user'; import { userGeneralSettingsSelectors } from '@/store/user/selectors'; import ContentBlock from '../../AssistantGroup/components/ContentBlock'; +import ContentBlocksScroll from '../../AssistantGroup/components/ContentBlocksScroll'; +import { resolveAssistantGroupFromMessages } from '../../AssistantGroup/utils/resolveAssistantGroupFromMessages'; import Usage from '../../components/Extras/Usage'; import AnimatedNumber from '../../components/Extras/Usage/UsageDetail/AnimatedNumber'; import { accumulateUsage, formatDuration, formatElapsedTime } from './utils'; const styles = createStaticStyles(({ css }) => ({ - contentScroll: css` - max-height: min(50vh, 300px); - `, instructionContent: css` overflow: auto; max-height: 300px; @@ -137,18 +126,17 @@ interface TaskMessagesProps { */ const ProcessingView = memo<{ accumulatedUsage: { cost?: number; totalTokens?: number }; - assistantId: string; - blocks: AssistantContentBlock[]; + messages: UIChatMessage[]; model?: string; provider?: string; startTime?: number; totalToolCalls: number; -}>(({ blocks, assistantId, startTime, model, provider, totalToolCalls, accumulatedUsage }) => { +}>(({ messages, startTime, model, provider, totalToolCalls, accumulatedUsage }) => { const { t } = useTranslation('chat'); const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); const [elapsedTime, setElapsedTime] = useState(0); const { ref, handleScroll } = useAutoScroll({ - deps: [blocks], + deps: [messages], enabled: true, }); @@ -204,19 +192,13 @@ const ProcessingView = memo<{ )}
- } - size={8} + - - {blocks.map((block) => ( - - ))} - - + /> {/* Usage display */} {isDevMode && model && provider && ( @@ -327,45 +309,10 @@ CompletedView.displayName = 'CompletedView'; const TaskMessages = memo( ({ messages, isProcessing = false, startTime, duration, model, provider, totalCost }) => { // Extract blocks and instruction from messages - const { blocks, assistantId, instruction } = useMemo(() => { - if (!messages || messages.length === 0) - return { assistantId: '', blocks: [], instruction: undefined }; - - const assistantGroupMessage = messages.find((item) => item.role === 'assistantGroup'); - const userMessage = messages.find((item) => item.role === 'user'); - - // If assistantGroup exists, use its children as blocks - if (assistantGroupMessage) { - return { - assistantId: assistantGroupMessage.id ?? '', - blocks: assistantGroupMessage.children ?? [], - instruction: userMessage?.content, - }; - } - - // Fallback: support plain assistant message (without tools) - // This handles cases where SubAgent returns a simple text response - const assistantMessage = messages.find((item) => item.role === 'assistant'); - if (assistantMessage) { - // Convert plain assistant message to block format - const block: AssistantContentBlock = { - content: assistantMessage.content || '', - id: assistantMessage.id, - }; - - // Copy optional fields if they exist - if (assistantMessage.error) block.error = assistantMessage.error; - if (assistantMessage.reasoning) block.reasoning = assistantMessage.reasoning; - - return { - assistantId: assistantMessage.id ?? '', - blocks: [block], - instruction: userMessage?.content, - }; - } - - return { assistantId: '', blocks: [], instruction: undefined }; - }, [messages]); + const { blocks, assistantId, instruction } = useMemo( + () => resolveAssistantGroupFromMessages(messages), + [messages], + ); // Calculate total tool calls const totalToolCalls = useMemo( @@ -387,8 +334,7 @@ const TaskMessages = memo( {isProcessing ? (