mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix(client): render handoff instructions as readable context
This commit is contained in:
parent
949b7d5f85
commit
4cd0d7ba9d
2 changed files with 211 additions and 30 deletions
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useId, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { EModelEndpoint, Constants } from 'librechat-data-provider';
|
||||
import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { unescapeJsonString } from './Parts/parseJsonField';
|
||||
import MessageIcon from '~/components/Share/MessageIcon';
|
||||
import { toolPanelSpacingClassName } from './disclosure';
|
||||
import { useLocalize, useExpandCollapse } from '~/hooks';
|
||||
|
|
@ -15,13 +16,87 @@ interface AgentHandoffProps {
|
|||
args: string | Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface HandoffField {
|
||||
key?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const PARTIAL_STRING_FIELD = /^\s*\{\s*"([^"\\]+)"\s*:\s*"((?:[^"\\]|\\.)*)/s;
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim();
|
||||
}
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function fieldsFromRecord(record: Record<string, unknown>): HandoffField[] {
|
||||
return Object.entries(record).reduce<HandoffField[]>((fields, [key, rawValue]) => {
|
||||
const value = formatValue(rawValue);
|
||||
if (value) {
|
||||
fields.push({ key, value });
|
||||
}
|
||||
return fields;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function parseHandoffFields(args: string | Record<string, unknown>): HandoffField[] {
|
||||
if (typeof args !== 'string') {
|
||||
return fieldsFromRecord(args);
|
||||
}
|
||||
|
||||
const trimmed = args.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return fieldsFromRecord(parsed as Record<string, unknown>);
|
||||
}
|
||||
const value = formatValue(parsed);
|
||||
return value ? [{ value }] : [];
|
||||
} catch {
|
||||
const partialField = trimmed.match(PARTIAL_STRING_FIELD);
|
||||
if (partialField) {
|
||||
const value = unescapeJsonString(partialField[2]);
|
||||
return value ? [{ key: partialField[1], value }] : [];
|
||||
}
|
||||
return trimmed.startsWith('{') ? [] : [{ value: trimmed }];
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(key: string): string {
|
||||
const words = key
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim();
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : key;
|
||||
}
|
||||
|
||||
const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const contentId = useId();
|
||||
const headingId = `${contentId}-heading`;
|
||||
const { style: expandStyle, ref: expandRef } = useExpandCollapse(showInfo);
|
||||
|
||||
useEffect(() => () => clearTimeout(copiedTimerRef.current), []);
|
||||
|
||||
const targetAgentId = useMemo(() => {
|
||||
if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||
return null;
|
||||
|
|
@ -36,28 +111,34 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
return agentsMap[targetAgentId];
|
||||
}, [agentsMap, targetAgentId]);
|
||||
|
||||
const args = useMemo(() => {
|
||||
if (typeof _args === 'string') {
|
||||
return _args;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(_args, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [_args]) as string;
|
||||
|
||||
const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]);
|
||||
const fields = useMemo(() => parseHandoffFields(_args), [_args]);
|
||||
const copyText = useMemo(
|
||||
() =>
|
||||
fields.length === 1
|
||||
? fields[0].value
|
||||
: fields
|
||||
.map(({ key, value }) => `${key ? `${fieldLabel(key)}\n` : ''}${value}`)
|
||||
.join('\n\n'),
|
||||
[fields],
|
||||
);
|
||||
const hasInfo = fields.length > 0;
|
||||
const agentName = targetAgent?.name || localize('com_ui_agent');
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(args);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
navigator.clipboard.writeText(copyText).then(
|
||||
() => {
|
||||
clearTimeout(copiedTimerRef.current);
|
||||
setIsCopied(true);
|
||||
copiedTimerRef.current = setTimeout(() => setIsCopied(false), 2000);
|
||||
},
|
||||
() => {
|
||||
setIsCopied(false);
|
||||
},
|
||||
);
|
||||
},
|
||||
[args],
|
||||
[copyText],
|
||||
);
|
||||
|
||||
const copyLabel = isCopied
|
||||
|
|
@ -77,6 +158,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
disabled={!hasInfo}
|
||||
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
|
||||
aria-expanded={hasInfo ? showInfo : undefined}
|
||||
aria-controls={hasInfo ? contentId : undefined}
|
||||
aria-label={`${localize('com_ui_transferred_to')} ${agentName}`}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-full ring-1 ring-border-light">
|
||||
|
|
@ -102,17 +184,21 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
/>
|
||||
)}
|
||||
</button>
|
||||
<div style={expandStyle}>
|
||||
<div id={contentId} style={expandStyle} aria-hidden={!showInfo || undefined}>
|
||||
<div className="overflow-hidden" ref={expandRef}>
|
||||
{hasInfo && (
|
||||
<div
|
||||
<section
|
||||
aria-labelledby={headingId}
|
||||
className={cn(
|
||||
toolPanelSpacingClassName,
|
||||
'group/handoff ml-8 rounded-xl border border-border-light bg-surface-secondary p-4 text-xs',
|
||||
'group/handoff ml-8 max-w-3xl border-l-2 border-border-medium py-1 pl-4 pr-1',
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-secondary">
|
||||
<div className="mb-1.5 flex min-h-5 items-center justify-between gap-2">
|
||||
<span
|
||||
id={headingId}
|
||||
className="text-[11px] font-semibold uppercase tracking-wide text-text-secondary"
|
||||
>
|
||||
{localize('com_ui_handoff_instructions')}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
|
|
@ -124,7 +210,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
aria-label={copyLabel}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg p-1 text-text-tertiary transition-opacity duration-150',
|
||||
'opacity-0 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100',
|
||||
'opacity-60 group-focus-within/handoff:opacity-100 group-hover/handoff:opacity-100',
|
||||
'hover:bg-surface-hover hover:text-text-primary',
|
||||
'focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
|
||||
)}
|
||||
|
|
@ -138,10 +224,27 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap leading-relaxed text-text-primary">
|
||||
{args}
|
||||
</pre>
|
||||
</div>
|
||||
{fields.length === 1 ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-text-primary">
|
||||
{fields[0].value}
|
||||
</p>
|
||||
) : (
|
||||
<dl className="space-y-3">
|
||||
{fields.map(({ key, value }, index) => (
|
||||
<div key={`${key ?? 'field'}-${index}`}>
|
||||
{key && (
|
||||
<dt className="mb-0.5 text-xs font-medium text-text-secondary">
|
||||
{fieldLabel(key)}
|
||||
</dt>
|
||||
)}
|
||||
<dd className="whitespace-pre-wrap break-words text-sm leading-6 text-text-primary">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import AgentHandoff from '../AgentHandoff';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
|
|
@ -9,6 +9,8 @@ jest.mock('~/hooks', () => ({
|
|||
com_ui_transferred_to: 'Transferred to',
|
||||
com_ui_agent: 'Agent',
|
||||
com_ui_handoff_instructions: 'Handoff instructions',
|
||||
com_ui_copy_to_clipboard: 'Copy to clipboard',
|
||||
com_ui_copied_to_clipboard: 'Copied to clipboard',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
|
|
@ -52,7 +54,7 @@ const renderAgentHandoff = (props: {
|
|||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
describe('AgentHandoff - A11Y accessibility stubs', () => {
|
||||
describe('AgentHandoff', () => {
|
||||
it('A11Y-01: renders a semantic button element when hasInfo is true', () => {
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
|
|
@ -85,7 +87,7 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
expect(button.className).toContain('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
it('A11Y-03: disabled button is rendered when hasInfo is false', () => {
|
||||
it('A11Y-04: disabled button is rendered when hasInfo is false', () => {
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: '',
|
||||
|
|
@ -94,4 +96,80 @@ describe('AgentHandoff - A11Y accessibility stubs', () => {
|
|||
const button = screen.getByRole('button', { name: /Transferred to/i });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders the handoff instruction as prose instead of JSON', () => {
|
||||
const { container } = renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: JSON.stringify({
|
||||
instructions: 'Review the rollout, validate retention, and report blockers.',
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Transferred to/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText('Review the rollout, validate retention, and report blockers.'),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector('pre')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/"instructions"/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the readable instruction rather than its JSON wrapper', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: JSON.stringify({ instructions: 'Review the rollout.' }),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Transferred to/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy to clipboard' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('Review the rollout.');
|
||||
expect(await screen.findByRole('button', { name: 'Copied to clipboard' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports object args and a custom handoff prompt key', () => {
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: { work_items: 'Verify the migration plan.' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Transferred to/i }));
|
||||
|
||||
expect(screen.getByText('Verify the migration plan.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Work items')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels each value when a legacy handoff contains multiple fields', () => {
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: JSON.stringify({
|
||||
work_items: 'Verify the migration plan.',
|
||||
priority: 'High',
|
||||
}),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Transferred to/i }));
|
||||
|
||||
expect(screen.getByText('Work items')).toBeInTheDocument();
|
||||
expect(screen.getByText('Verify the migration plan.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Priority')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a partially streamed instruction without exposing JSON syntax', () => {
|
||||
renderAgentHandoff({
|
||||
name: 'lc_transfer_to_agent-123',
|
||||
args: '{"instructions":"Review the rollout and report',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Transferred to/i }));
|
||||
|
||||
expect(screen.getByText('Review the rollout and report')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/"instructions"/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue