mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-29 13:31:29 +00:00
fix(client): make group identities collision-proof and localize handoff labels
Disambiguate duplicate tool-group identities by how many earlier groups already claimed the same provider tool-call id. Those ids repeat across agents in a handoff transcript and restart per turn within one agent, and a lone tool with reasoning now groups too, so sibling groups could share a React key and one expansion override. Occurrence order is stable because groups are append-only, and the first occurrence keeps the bare id, so an id that never repeats is unchanged and still survives a content-index shift. Restore a stashed ask message regardless of the current Save drafts setting. The stash is only ever created while saving is off, so it never reached the conversation draft; gating the restore on the setting dropped it whenever the user enabled saving between expanding and collapsing. Localize the handoff field labels the SDK defines (instruction, instructions, context). Any other key is an admin-authored promptKey from the agent graph, so it stays as written, like an agent's own name.
This commit is contained in:
parent
e0fd022d68
commit
5992cd78ef
4 changed files with 58 additions and 36 deletions
|
|
@ -4,6 +4,7 @@ import { ChevronDown } from 'lucide-react';
|
|||
import { Button } from '@librechat/client';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import CopyButton from '~/components/Messages/Content/CopyButton';
|
||||
import { unescapeJsonString } from './Parts/parseJsonField';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
|
|
@ -78,7 +79,23 @@ function parseHandoffFields(args: string | Record<string, unknown>): HandoffFiel
|
|||
}
|
||||
}
|
||||
|
||||
function fieldLabel(key: string): string {
|
||||
/**
|
||||
* The SDK's own prompt keys, which are the ones that carry product meaning.
|
||||
* Any other key is an admin-authored `promptKey` from the agent graph (see
|
||||
* `handoffPromptKeyCompatibility`), so it is author content with no
|
||||
* localization key to map to and is left as written, like an agent's own name.
|
||||
*/
|
||||
const HANDOFF_FIELD_LABELS: Record<string, TranslationKeys> = {
|
||||
instruction: 'com_ui_handoff_field_instructions',
|
||||
instructions: 'com_ui_handoff_field_instructions',
|
||||
context: 'com_ui_handoff_field_context',
|
||||
};
|
||||
|
||||
function fieldLabel(key: string, localize: (translationKey: TranslationKeys) => string): string {
|
||||
const known = HANDOFF_FIELD_LABELS[key.toLowerCase()];
|
||||
if (known) {
|
||||
return localize(known);
|
||||
}
|
||||
const words = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
|
|
@ -118,9 +135,9 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
fields.length === 1
|
||||
? fields[0].value
|
||||
: fields
|
||||
.map(({ key, value }) => `${key ? `${fieldLabel(key)}\n` : ''}${value}`)
|
||||
.map(({ key, value }) => `${key ? `${fieldLabel(key, localize)}\n` : ''}${value}`)
|
||||
.join('\n\n'),
|
||||
[fields],
|
||||
[fields, localize],
|
||||
);
|
||||
const hasInfo = fields.length > 0;
|
||||
const agentName = targetAgent?.name || localize('com_ui_agent');
|
||||
|
|
@ -215,7 +232,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
<div key={`${key ?? 'field'}-${index}`}>
|
||||
{key && (
|
||||
<dt className="mb-0.5 text-xs font-medium text-text-secondary">
|
||||
{fieldLabel(key)}
|
||||
{fieldLabel(key, localize)}
|
||||
</dt>
|
||||
)}
|
||||
<dd className="whitespace-pre-wrap break-words text-sm leading-6 text-text-primary">
|
||||
|
|
|
|||
|
|
@ -55,12 +55,7 @@ const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string =
|
|||
for (const { part, idx } of parts) {
|
||||
const toolCallId = getToolCallId(part);
|
||||
if (toolCallId) {
|
||||
/** Provider tool-call ids repeat across agents in a handoff transcript
|
||||
* (`call_0`), and a lone tool with reasoning now groups too, so two
|
||||
* sibling groups would otherwise share one React key and one expansion
|
||||
* override: toggling either would move both. */
|
||||
const agentId = getPartAgentId(part);
|
||||
return agentId ? `tool:${agentId}:${toolCallId}` : `tool:${toolCallId}`;
|
||||
return `tool:${toolCallId}`;
|
||||
}
|
||||
if (firstToolIdx === undefined && part?.type === ContentTypes.TOOL_CALL) {
|
||||
firstToolIdx = idx;
|
||||
|
|
@ -441,22 +436,30 @@ const ContentParts = memo(function ContentParts({
|
|||
}, [absoluteIndexAt, content]);
|
||||
const postSteerAuthors = resumeAuthors ?? detectedResumeAuthors;
|
||||
|
||||
const groupedParts = useMemo(
|
||||
() =>
|
||||
groupSequentialToolCalls(sequentialParts).map((group) => {
|
||||
if (group.type === 'single') {
|
||||
return group;
|
||||
}
|
||||
const groupId = getToolGroupId(group.parts, fallbackScope);
|
||||
const groupAttachments = group.parts.flatMap(
|
||||
({ part }) =>
|
||||
filterAttachmentsForPart(attachmentMap[getToolCallId(part)], getPartAgentId(part)) ??
|
||||
[],
|
||||
);
|
||||
return { ...group, groupId, groupAttachments };
|
||||
}),
|
||||
[sequentialParts, attachmentMap, fallbackScope],
|
||||
);
|
||||
const groupedParts = useMemo(() => {
|
||||
/** Provider tool-call ids are NOT unique: they repeat across agents in a
|
||||
* handoff transcript and restart per turn within one agent, and a lone
|
||||
* tool with reasoning now groups too, so sibling groups could share a
|
||||
* React key and one expansion override. Disambiguated by how many earlier
|
||||
* groups already claimed the same id, which is stable because group order
|
||||
* is append-only. The FIRST occurrence keeps the bare id, so an id that
|
||||
* never repeats is unchanged and survives a content-index shift. */
|
||||
const claimedGroupIds = new Map<string, number>();
|
||||
return groupSequentialToolCalls(sequentialParts).map((group) => {
|
||||
if (group.type === 'single') {
|
||||
return group;
|
||||
}
|
||||
const baseGroupId = getToolGroupId(group.parts, fallbackScope);
|
||||
const claimed = claimedGroupIds.get(baseGroupId) ?? 0;
|
||||
claimedGroupIds.set(baseGroupId, claimed + 1);
|
||||
const groupId = claimed === 0 ? baseGroupId : `${baseGroupId}#${claimed}`;
|
||||
const groupAttachments = group.parts.flatMap(
|
||||
({ part }) =>
|
||||
filterAttachmentsForPart(attachmentMap[getToolCallId(part)], getPartAgentId(part)) ?? [],
|
||||
);
|
||||
return { ...group, groupId, groupAttachments };
|
||||
});
|
||||
}, [sequentialParts, attachmentMap, fallbackScope]);
|
||||
|
||||
/** The re-attribution node for a part resuming after a steer block, shared
|
||||
* by the sequential path and the parallel renderer's sequential stretches. */
|
||||
|
|
|
|||
|
|
@ -227,16 +227,14 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
runHandoff(() => {
|
||||
if (!batchMode) {
|
||||
setAnswerDrafts((current) => ({ ...current, [liveAsk.actionId]: composerAnswer }));
|
||||
if (!saveDrafts) {
|
||||
/** Hand back whatever ordinary message was typed while this
|
||||
* question owned the card, rather than clearing the composer and
|
||||
* losing it. */
|
||||
const released = releasedComposerText[liveAsk.actionId];
|
||||
if (released) {
|
||||
formContext?.setValue('text', released);
|
||||
} else {
|
||||
formContext?.reset();
|
||||
}
|
||||
/** An existing stash is restored regardless of the CURRENT
|
||||
* preference: it was captured while saving was off, so it never
|
||||
* reached the conversation draft and this is its only recovery path.
|
||||
* Gating on `!saveDrafts` dropped it whenever the user enabled
|
||||
* saving between expanding and collapsing. */
|
||||
const released = releasedComposerText[liveAsk.actionId];
|
||||
if (released) {
|
||||
formContext?.setValue('text', released);
|
||||
setReleasedComposerText((current) => {
|
||||
if (current[liveAsk.actionId] == null) {
|
||||
return current;
|
||||
|
|
@ -245,6 +243,8 @@ export default function useAskAnswerMode(conversationId?: string | null) {
|
|||
delete next[liveAsk.actionId];
|
||||
return next;
|
||||
});
|
||||
} else if (!saveDrafts) {
|
||||
formContext?.reset();
|
||||
}
|
||||
}
|
||||
setCollapsedIds((prev) =>
|
||||
|
|
|
|||
|
|
@ -1386,6 +1386,8 @@
|
|||
"com_ui_greeting_working_on": "What are we working on?",
|
||||
"com_ui_greeting_working_on_named": "What are we working on, {{name}}?",
|
||||
"com_ui_group": "Group",
|
||||
"com_ui_handoff_field_context": "Context",
|
||||
"com_ui_handoff_field_instructions": "Instructions",
|
||||
"com_ui_handoff_instructions": "Handoff instructions",
|
||||
"com_ui_happy_birthday": "It's my 1st birthday!",
|
||||
"com_ui_header_format": "Header Format",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue