diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 13c9f9d4da..e06aedf44c 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -223,7 +223,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} toolName={toolCall.name} - args={typeof toolCall.args === 'string' ? toolCall.args : ''} + args={toolCall.args ?? ''} output={toolCall.output ?? ''} attachments={attachments} hideAttachments={hideAttachments} @@ -318,6 +318,7 @@ const Part = memo(function Part({ } else if (toolCall.name === Tools.web_search) { return ( setIsCopied(false), 3000); }, [command]); + /** The model-authored `intent` streams as the FIRST args key, so it is the + * live label from the earliest delta — before the command exists and while + * it runs. It persists as the settled label too (completion is a UI state, + * not a tense change); the generic texts are the no-intent fallback. */ + const intent = useToolCallIntent(args); const inProgressText = (() => { + if (intent != null) { + return intent; + } if (isWritingCommand) { return localize('com_ui_writing_command'); } @@ -95,7 +104,7 @@ export default function BashCall({ finishedText={ cancelled ? localize('com_ui_cancelled') - : (backgroundFinishedText ?? localize('com_ui_command_finished')) + : (backgroundFinishedText ?? intent ?? localize('com_ui_command_finished')) } errorSuffix={ (hasError && !cancelled) || backgroundFailed diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index f51e0c7750..6821103422 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -9,6 +9,7 @@ import useLazyHighlight from './useLazyHighlight'; import useToolCallState from './useToolCallState'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; +import { useToolCallIntent } from './intent'; import { useLocalize } from '~/hooks'; import Stdout from './Stdout'; import { cn } from '~/utils'; @@ -74,6 +75,9 @@ export default function ExecuteCode({ }) { const localize = useLocalize(); const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs); + /** Model-authored live label, streamed as the first args key; persists as + * the settled label (completion is a UI state, not a tense change). */ + const intent = useToolCallIntent(args); const sandboxStarting = useRecoilValue(sandboxStartingByToolCallId(toolCallId ?? '')); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = @@ -106,12 +110,13 @@ export default function ExecuteCode({ progress={progress} onClick={toggleCode} inProgressText={ - sandboxStarting ? localize('com_ui_sandbox_starting') : localize('com_ui_analyzing') + intent ?? + (sandboxStarting ? localize('com_ui_sandbox_starting') : localize('com_ui_analyzing')) } finishedText={ cancelled ? localize('com_ui_cancelled') - : (backgroundFinishedText ?? localize('com_ui_analyzing_finished')) + : (backgroundFinishedText ?? intent ?? localize('com_ui_analyzing_finished')) } errorSuffix={ (hasError && !cancelled) || backgroundFailed diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index ae8b6fe993..e24febcf06 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -8,6 +8,7 @@ import useLazyHighlight from './useLazyHighlight'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; import { langFromPath } from './ReadFileCall'; +import { useToolCallIntent } from './intent'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -128,6 +129,7 @@ export default function FileAuthoringCall({ * `Created`/`Updated`, so key the finished label off it for truthfulness. */ const overwrote = isCreate && output.startsWith('Updated '); const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); + const intent = useToolCallIntent(args); const authoredContent = useMemo(() => parseJsonField(args, 'content'), [args]); const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]); const fileName = filePath.split('/').pop() || filePath; @@ -162,11 +164,16 @@ export default function FileAuthoringCall({
- +
{isAgentStyle && !hideAttachments && (
diff --git a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx index c0c6b128f3..490539419b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/OpenAIImageGen/ProgressText.tsx @@ -6,10 +6,13 @@ export default function ProgressText({ progress, error, toolName = '', + intent, }: { progress: number; error?: boolean; toolName?: string; + /** Model-authored label; wins over the phase texts (error state excepted). */ + intent?: string; }) { const localize = useLocalize(); @@ -17,6 +20,9 @@ export default function ProgressText({ if (error) { return localize('com_ui_image_gen_failed'); } + if (intent != null) { + return intent; + } if (toolName === 'image_edit_oai') { if (progress >= 1) { diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx index da9d5e3806..0519d51036 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx @@ -7,6 +7,7 @@ import useLazyHighlight from './useLazyHighlight'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; import parseJsonField from './parseJsonField'; +import { useToolCallIntent } from './intent'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -80,6 +81,7 @@ export default function ReadFileCall({ }) { const localize = useLocalize(); const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); + const intent = useToolCallIntent(args); const fileName = filePath.split('/').pop() || filePath; const lang = useMemo(() => langFromPath(filePath), [filePath]); @@ -94,9 +96,11 @@ export default function ReadFileCall({ parseJsonField(args, 'skillName'), [args]); + const intent = useToolCallIntent(args); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand); @@ -38,11 +40,11 @@ export default function SkillCall({ { if (hasError) return localize('com_ui_subagent_errored'); if (cancelled) return localize('com_ui_subagent_cancelled'); + if (intent != null) return intent; if (running) return localize('com_ui_subagent_running'); return localize('com_ui_subagent_complete'); }; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx index b1100f37b9..e4014e3303 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/BashCall.test.tsx @@ -122,6 +122,85 @@ describe('BashCall status text', () => { ); }); +describe('BashCall intent label', () => { + it('shows a streaming intent before any other arg exists (first key, partial JSON)', () => { + renderBashCall('{"intent":"Checking the countdown ta'); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Checking the countdown ta'); + expect(screen.queryByText('Writing command')).not.toBeInTheDocument(); + }); + + it('keeps the intent as the in-progress label once the command has streamed', () => { + renderBashCall('{"intent":"Waiting for the task to settle","command":"sleep 8; echo waited"}'); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Waiting for the task to settle'); + expect(screen.queryByText('Running command')).not.toBeInTheDocument(); + expect(screen.getByText(/sleep 8/)).toBeInTheDocument(); + }); + + it('keeps the intent as the settled label (completion is a UI state, not a tense change)', () => { + render( + + + , + ); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Waiting for the task to settle'); + expect(screen.queryByText('Finished running')).not.toBeInTheDocument(); + }); + + it('falls back to the generic labels when no intent is present', () => { + renderBashCall({ command: 'sleep 10' }); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command'); + }); + + it('ignores a non-string intent arg (a business param, not the label contract)', () => { + renderBashCall({ intent: { nested: true }, command: 'sleep 10' }); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command'); + }); + + it('ignores a non-string intent in complete SERIALIZED args (no String() coercion)', () => { + renderBashCall('{"intent":{"topic":"billing"},"command":"sleep 10"}'); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command'); + expect(screen.queryByText(/object Object/)).not.toBeInTheDocument(); + }); + + it('ignores an intent that is not the FIRST args key (label contract is first-position)', () => { + renderBashCall('{"command":"sleep 10","intent":"billing_inquiry"}'); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Running command'); + expect(screen.queryByText('billing_inquiry')).not.toBeInTheDocument(); + }); + + it('bounds a runaway intent to a single 256-char line', () => { + const runaway = `Checking ${'a very long clause '.repeat(30)}end`; + renderBashCall({ intent: runaway, command: 'sleep 10' }); + const text = screen.getByTestId('progress-text').textContent ?? ''; + expect(text.length).toBeLessThanOrEqual(256); + expect(text.endsWith('…')).toBe(true); + }); + + it('never splits a surrogate pair at the truncation boundary', () => { + const straddling = `${'x'.repeat(254)}😀 and more text to exceed the bound`; + renderBashCall({ intent: straddling, command: 'sleep 10' }); + const text = screen.getByTestId('progress-text').textContent ?? ''; + expect(text.endsWith('x…')).toBe(true); + expect(text).not.toContain('�'); + }); + + it('decodes unicode escapes in a streaming intent (no literal \\uXXXX flash)', () => { + renderBashCall('{"intent":"Checking caf\\u00e9 menu da'); + expect(screen.getByTestId('progress-text')).toHaveTextContent('Checking café menu da'); + }); + + it('keeps a terminal lone high surrogate once the value is settled (matches JSON.parse)', () => { + renderBashCall('{"intent":"odd \\ud83d","command":"sleep 10"}'); + const text = screen.getByTestId('progress-text').textContent ?? ''; + expect(text).toBe('odd \ud83d'); + }); +}); + describe('BashCall backgrounded calls', () => { const HANDLE_OUTPUT = JSON.stringify({ background_task_id: 'task-1', diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts index facffb222f..b8d67f03f8 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts @@ -98,9 +98,47 @@ describe('parseJsonField', () => { expect(parseJsonField(partial, 'file_path')).toBe('C:\\note'); }); - it('preserves unknown escape sequences', () => { - const partial = '{"command":"tab\\there","incomplete":'; - expect(parseJsonField(partial, 'command')).toBe('tab\\there'); + it('decodes the full JSON escape set (matches the settled JSON.parse rendering)', () => { + const partial = '{"command":"tab\\there\\rreturn\\bback\\fform\\/slash","incomplete":'; + expect(parseJsonField(partial, 'command')).toBe('tab\there\rreturn\bback\fform/slash'); + }); + + it('preserves genuinely unknown escape sequences', () => { + const partial = '{"command":"odd\\qescape","incomplete":'; + expect(parseJsonField(partial, 'command')).toBe('odd\\qescape'); + }); + + it('decodes \\uXXXX escapes mid-stream, including surrogate pairs', () => { + const partial = '{"intent":"Checking caf\\u00e9 menu \\ud83d\\ude00 da'; + expect(parseJsonField(partial, 'intent')).toBe('Checking café menu 😀 da'); + }); + + it('drops an incomplete \\uXX escape at the stream edge instead of showing it literally', () => { + const partial = '{"intent":"Checking caf\\u00e'; + expect(parseJsonField(partial, 'intent')).toBe('Checking caf'); + }); + + it('holds back the high half of a split surrogate pair at the stream edge', () => { + const partial = '{"intent":"Searching \\ud83d'; + expect(parseJsonField(partial, 'intent')).toBe('Searching '); + }); + + it.each(['\\', '\\u', '\\uD', '\\uDE0'])( + 'holds the high surrogate while the low escape is still streaming: "\\ud83d%s"', + (lowPrefix) => { + const partial = `{"intent":"Searching \\ud83d${lowPrefix}`; + expect(parseJsonField(partial, 'intent')).toBe('Searching '); + }, + ); + + it('emits a lone high surrogate when followed by ordinary text (matches JSON.parse)', () => { + const partial = '{"intent":"odd \\ud83d tail","incomplete":'; + expect(parseJsonField(partial, 'intent')).toBe('odd \ud83d tail'); + }); + + it('keeps a malformed \\u (bad hex mid-string) literal rather than corrupting the tail', () => { + const partial = '{"command":"regex \\uZZZZ tail","incomplete":'; + expect(parseJsonField(partial, 'command')).toBe('regex \\uZZZZ tail'); }); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/intent.ts b/client/src/components/Chat/Messages/Content/Parts/intent.ts new file mode 100644 index 0000000000..25e8b16311 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/intent.ts @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; +import { unescapeJsonString } from './parseJsonField'; + +/** + * Single-line clamp mirroring the SDK's outcome-label bound + * (`MAX_OUTCOME_CHARS` in `@librechat/agents`): the label is one progress + * line in UI chrome rendered with `whitespace-nowrap`, so a verbose or + * malformed model response must not extend across adjacent controls. + */ +const MAX_INTENT_CHARS = 256; + +/** + * Head window scanned for the label. The gate + a full 256-char label (with + * escapes) fit comfortably; bounding the scan keeps per-delta work constant + * even when a tool streams megabytes of `code`/`content` after the intent. + */ +const INTENT_SCAN_CHARS = 2048; + +/** + * Anchored prefix extractor: the label contract puts `intent` FIRST + * ("ALWAYS write this field FIRST" — that ordering is the entire streaming + * mechanism), so a single anchored match both gates out a tool's own + * business parameter that merely shares the name (e.g. a CRM's + * `{"q":"acme","intent":"billing"}`, which serializes wherever the model + * put it) and captures the string value, complete or still streaming + * (tolerating a missing closing quote and a dangling escape). A non-string + * first-key `intent` (`{"intent":{...}}`) never matches the opening quote, + * so schema-invalid calls keep their generic label without any JSON.parse. + */ +const INTENT_PREFIX_REGEX = /^\s*\{\s*"intent"\s*:\s*"((?:[^"\\]|\\.)*)(")?/; + +function boundIntentLabel(label: string): string | undefined { + const singleLine = label.replace(/\s+/g, ' ').trim(); + if (singleLine === '') { + return undefined; + } + if (singleLine.length <= MAX_INTENT_CHARS) { + return singleLine; + } + let head = singleLine.slice(0, MAX_INTENT_CHARS - 1); + const lastCode = head.charCodeAt(head.length - 1); + /** Never split a surrogate pair at the cut — a lone high surrogate + * renders as a replacement glyph before the ellipsis. */ + if (lastCode >= 0xd800 && lastCode <= 0xdbff) { + head = head.slice(0, -1); + } + return `${head}…`; +} + +/** + * The model-authored `intent` label for a tool call: one sentence, injected + * as the FIRST property of the tool's schema (SDK-native or host-injected), + * so it is the first key providers stream in the args — which is what lets + * a card show it as the call's live status label from the earliest delta, + * before the rest of the args exist. Returns undefined until any non-empty + * text has streamed, so callers can fall back to their generic label. + * + * Extraction never parses the args buffer: one anchored regex over a + * bounded head window does the gating and the capture, so per-delta cost + * stays constant while a large `code`/`content` argument streams behind + * the label. + * + * The label persists unchanged when the call settles: completion is a UI + * state (the shimmer stopping), not a tense change (see `applyOutcome` in + * `@librechat/agents` for why there is deliberately no rewrite). + */ +export function useToolCallIntent(args?: string | Record): string | undefined { + return useMemo(() => { + if (args == null) { + return undefined; + } + if (typeof args === 'object') { + if (Object.keys(args)[0] !== 'intent') { + return undefined; + } + const value = args.intent; + return typeof value === 'string' ? boundIntentLabel(value) : undefined; + } + const match = INTENT_PREFIX_REGEX.exec(args.slice(0, INTENT_SCAN_CHARS)); + if (!match) { + return undefined; + } + /** A captured closing quote means the value is settled: decode it + * without the stream-edge hold-backs, so a value genuinely ending in + * a lone high surrogate matches its JSON.parse rendering. */ + return boundIntentLabel(unescapeJsonString(match[1], match[2] !== '"')); + }, [args]); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts index e177fff1dd..5acc8bc11f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts +++ b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts @@ -21,16 +21,70 @@ function fieldRegex(field: string, flags?: string): RegExp { return new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)(?:"|\\\\?$)`, flags); } -function unescapeJsonString(value: string): string { - return value.replace(/\\(.)/g, (_, c: string) => { - if (c === 'n') { - return '\n'; +const SIMPLE_ESCAPES: Record = { + n: '\n', + t: '\t', + r: '\r', + b: '\b', + f: '\f', + '"': '"', + '\\': '\\', + '/': '/', +}; + +/** + * Decodes the full JSON string escape set so a partially streamed value + * renders exactly as its settled `JSON.parse` form will — without this, + * `café` displays literally mid-stream and then snaps to `café` once + * the object becomes valid JSON. Stream-boundary incompletions (a dangling + * `\`, a partial `\uXX`, or the high half of a split surrogate pair) are + * dropped rather than shown, since the next delta completes them; unknown + * escapes elsewhere are preserved literally. Callers that KNOW the value is + * settled (its closing quote was present) pass `streaming: false` so a value + * genuinely ending in a lone high surrogate keeps its final code unit, + * matching `JSON.parse`. + */ +export function unescapeJsonString(value: string, streaming = true): string { + let out = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch !== '\\') { + out += ch; + continue; } - if (c === '"' || c === '\\') { - return c; + const next = value[i + 1]; + if (next === undefined) { + break; } - return `\\${c}`; - }); + i++; + if (next !== 'u') { + out += SIMPLE_ESCAPES[next] ?? `\\${next}`; + continue; + } + const hex = value.slice(i + 1, i + 5); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { + if (streaming && i + 5 > value.length) { + return out; + } + out += '\\u'; + continue; + } + i += 4; + const code = parseInt(hex, 16); + if (streaming && code >= 0xd800 && code <= 0xdbff) { + /** Hold back a high surrogate while its low half could still be + * streaming in — the remainder being any proper prefix of `\uXXXX` + * (including the empty string). A complete following escape decodes + * on the next iteration and composes the pair; anything else means + * the lone surrogate is real data (matches `JSON.parse`). */ + const rest = value.slice(i + 1); + if (/^(?:\\(?:u[0-9a-fA-F]{0,3})?)?$/.test(rest)) { + return out; + } + } + out += String.fromCharCode(code); + } + return out; } /** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */ diff --git a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx index 97f86fdb75..9c52fe25c2 100644 --- a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx +++ b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx @@ -8,6 +8,7 @@ import { useLocalize, useProgress, useExpandCollapse } from '~/hooks'; import { ToolIcon, OutputRenderer, isError } from './ToolOutput'; import FilePreviewDialog from './FilePreviewDialog'; import { sortPagesByRelevance, cn } from '~/utils'; +import { useToolCallIntent } from './Parts/intent'; import { useGetFiles } from '~/data-provider'; import ProgressText from './ProgressText'; import store from '~/store'; @@ -324,18 +325,24 @@ function FileHeader({ export default function RetrievalCall({ initialProgress = 0.1, isSubmitting, + args, output, attachments, onExpand, }: { initialProgress: number; isSubmitting: boolean; + args?: string | Record; output?: string; attachments?: TAttachment[]; onExpand?: () => void; }) { const progress = useProgress(initialProgress); const localize = useLocalize(); + /** Model-authored live label (injected when file_search is opted into + * describe_intent); persists as the settled label. The sr-only live + * region below deliberately keeps its stable generic value. */ + const intent = useToolCallIntent(args); const errorState = typeof output === 'string' && isError(output); const cancelled = !isSubmitting && initialProgress < 1 && !errorState; @@ -422,15 +429,15 @@ export default function RetrievalCall({ if (cancelled) { return localize('com_ui_cancelled'); } - return localize('com_ui_retrieved_files'); + return intent ?? localize('com_ui_retrieved_files'); })()}
diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index bc3efc4605..6c1231920c 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -13,6 +13,7 @@ import type { TAttachment } from 'librechat-data-provider'; import { useLocalize, useProgress, useExpandCollapse } from '~/hooks'; import { ToolIcon, getToolIconType, isError } from './ToolOutput'; import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; +import { useToolCallIntent } from './Parts/intent'; import { AttachmentGroup } from './Parts'; import ToolCallInfo from './ToolCallInfo'; import ProgressText from './ProgressText'; @@ -187,10 +188,18 @@ export default function ToolCall({ return undefined; }, [isMCPToolCall, mcpServerName, domain, localize]); + /** Model-authored live label, streamed as the first args key (injected by + * the `tool_intents` capability); persists as the settled label — + * completion is a UI state, not a tense change. */ + const intent = useToolCallIntent(_args); + const getFinishedText = () => { if (cancelled) { return localize('com_ui_cancelled'); } + if (intent != null) { + return intent; + } if (isMCPToolCall === true) { return localize('com_assistants_completed_function', { 0: function_name }); } @@ -206,6 +215,10 @@ export default function ToolCall({ return ( <> + {/* The live region gets a STABLE in-progress value: the streaming + intent grows on every delta, and an atomic polite region would + re-announce the whole sentence each time. The settled intent is + announced once via getFinishedText. */} {(() => { if (progress < 1 && !showCancelled) { @@ -225,9 +238,10 @@ export default function ToolCall({ progress={progress} onClick={handleToggleInfo} inProgressText={ - function_name + intent ?? + (function_name ? localize('com_assistants_running_var', { 0: function_name }) - : localize('com_assistants_running_action') + : localize('com_assistants_running_action')) } authText={ !showCancelled && authDomain.length > 0 ? localize('com_ui_requires_auth') : undefined diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index c8d76a04b2..172a1b00db 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -6,6 +6,7 @@ import type { TAttachment, ValidSource, SearchResultData } from 'librechat-data- import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard'; import { StackedFavicons } from '~/components/Web/Sources'; import { useLocalize, useExpandCollapse } from '~/hooks'; +import { useToolCallIntent } from './Parts/intent'; import { useSearchContext } from '~/Providers'; import cn from '~/utils/cn'; import store from '~/store'; @@ -80,18 +81,23 @@ export default function WebSearch({ initialProgress: progress = 0.1, isSubmitting, isLast, + args, output, attachments, onExpand, }: { isLast?: boolean; isSubmitting: boolean; + args?: string | Record; output?: string | null; initialProgress: number; attachments?: TAttachment[]; onExpand?: () => void; }) { const localize = useLocalize(); + /** Model-authored live label (web_search carries `intent` natively); + * persists as the settled label like the other tool cards. */ + const intent = useToolCallIntent(args); const { searchResults } = useSearchContext(); const error = typeof output === 'string' && output.toLowerCase().includes('error processing'); @@ -105,8 +111,12 @@ export default function WebSearch({ const effectiveProgress = hasResults && !isSubmitting ? 1 : progress; const cancelled = (!isSubmitting && effectiveProgress < 1) || error === true; - const complete = !isLast && effectiveProgress === 1; const finalizing = isSubmitting && isLast && effectiveProgress === 1; + /** A search that is the message's FINAL part stays "finalizing" only while + * the submission is live — afterwards it must settle like any other call, + * or the completed label (and its settled intent announcement) never + * renders and the card shimmers forever. */ + const complete = effectiveProgress === 1 && !finalizing && (!isLast || !isSubmitting); const ownTurn = useMemo((): string => { if (!attachments) { @@ -156,7 +166,10 @@ export default function WebSearch({ }, [searchResults, complete, finalizing, ownTurn]); const showSources = streamingSources.length > 0; - const progressText = useMemo(() => { + /** Stable phase text: the live region must not re-announce the growing + * intent on every delta, so it always gets this value while streaming; + * the settled intent is announced once via the completed branch. */ + const genericProgressText = useMemo(() => { let text: ProgressKeys = ownTurn !== '0' ? 'com_ui_web_searching_again' : 'com_ui_web_searching'; if (showSources) { @@ -167,6 +180,7 @@ export default function WebSearch({ } return localize(text); }, [ownTurn, localize, showSources, finalizing]); + const progressText = intent ?? genericProgressText; const autoExpand = useRecoilValue(store.autoExpandTools); const sourceCount = allSources.length; @@ -195,7 +209,7 @@ export default function WebSearch({ if (complete) { const hasSourceData = sourceCount > 0; - const completedText = localize('com_ui_web_searched'); + const completedText = intent ?? localize('com_ui_web_searched'); return (
@@ -271,7 +285,7 @@ export default function WebSearch({ return (
- {progressText} + {genericProgressText} {showSources && }