mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 07:36:25 +00:00
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
import { useRecoilValue } from 'recoil';
|
|
import { Button } from '@librechat/client';
|
|
import { ContentTypes } from 'librechat-data-provider';
|
|
import { CSSTransition } from 'react-transition-group';
|
|
import { CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-react';
|
|
import type { TMessageContentParts } from 'librechat-data-provider';
|
|
import type { ChildActivity, ChildActivityItem } from './adapters';
|
|
import type { TranslationKeys } from '~/hooks';
|
|
import { isAbnormalTerminalStatus, subagentStatusIcon, subagentStatusLabelKey } from './status';
|
|
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
|
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
|
import Container from '~/components/Chat/Messages/Content/Container';
|
|
import { EmptyText } from '~/components/Chat/Messages/Content/Parts';
|
|
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
|
import { useLocalize } from '~/hooks';
|
|
import { cn } from '~/utils';
|
|
import store from '~/store';
|
|
|
|
const AT_BOTTOM_THRESHOLD_PX = 120;
|
|
const CONTROL_ACTION_LABELS = {
|
|
steer: 'com_ui_subagent_control_steer',
|
|
queue: 'com_ui_subagent_control_queue',
|
|
interrupt: 'com_ui_subagent_control_interrupt',
|
|
cancel: 'com_ui_subagent_control_cancel',
|
|
cancel_message: 'com_ui_subagent_control_cancel_message',
|
|
} as const satisfies Record<string, TranslationKeys>;
|
|
const CONTROL_STATUS_LABELS = {
|
|
submitted: 'com_ui_subagent_control_status_submitted',
|
|
accepted: 'com_ui_subagent_control_status_accepted',
|
|
applied: 'com_ui_subagent_control_status_applied',
|
|
rejected: 'com_ui_subagent_control_status_rejected',
|
|
failed: 'com_ui_subagent_control_status_failed',
|
|
} as const satisfies Record<string, TranslationKeys>;
|
|
const CONTROL_REASON_LABELS: Record<string, TranslationKeys> = {
|
|
control_not_found: 'com_ui_subagent_control_reason_control_not_found',
|
|
invalid_command: 'com_ui_subagent_control_reason_invalid_command',
|
|
owner_unavailable: 'com_ui_subagent_control_reason_owner_unavailable',
|
|
task_inaccessible: 'com_ui_subagent_control_reason_task_inaccessible',
|
|
task_cancelled: 'com_ui_subagent_control_reason_task_cancelled',
|
|
task_completed: 'com_ui_subagent_control_reason_task_completed',
|
|
task_failed: 'com_ui_subagent_control_reason_task_failed',
|
|
task_not_running: 'com_ui_subagent_control_reason_task_not_running',
|
|
withdrawn: 'com_ui_subagent_control_reason_withdrawn',
|
|
};
|
|
|
|
function SubagentControlHistory({
|
|
controls,
|
|
onCancelControl,
|
|
}: {
|
|
controls: NonNullable<ChildActivity['controls']>;
|
|
onCancelControl?: (controlId: string) => void;
|
|
}) {
|
|
const localize = useLocalize();
|
|
if (controls.length === 0) return null;
|
|
/** Storage keeps actionable accepted receipts ahead of bounded terminal history.
|
|
* Presentation restores chronology without changing that retention priority. */
|
|
const chronologicalControls = controls
|
|
.map((control, index) => ({ control, index }))
|
|
.sort(
|
|
(left, right) =>
|
|
left.control.createdAt.localeCompare(right.control.createdAt) || left.index - right.index,
|
|
)
|
|
.map(({ control }) => control);
|
|
return (
|
|
<section aria-label={localize('com_ui_subagent_control_history')} className="mb-3 space-y-2">
|
|
{chronologicalControls.map((control) => {
|
|
const pending = control.status === 'submitted' || control.status === 'accepted';
|
|
let StatusIcon = XCircle;
|
|
if (pending) StatusIcon = Clock3;
|
|
if (control.status === 'applied') StatusIcon = CheckCircle2;
|
|
return (
|
|
<div
|
|
key={control.invocationId}
|
|
className="rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<StatusIcon size={14} aria-hidden className="shrink-0 text-text-secondary" />
|
|
<span className="font-medium">{localize(CONTROL_ACTION_LABELS[control.action])}</span>
|
|
<span className="ml-auto text-xs text-text-secondary" aria-live="polite">
|
|
{localize(CONTROL_STATUS_LABELS[control.status])}
|
|
</span>
|
|
</div>
|
|
{control.message != null && control.message !== '' && (
|
|
<div className="mt-1 break-words text-text-secondary">
|
|
{control.message}
|
|
{control.messageTruncated === true && (
|
|
<span className="ml-1 text-xs italic">
|
|
{localize('com_ui_subagent_control_message_truncated')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{control.reason != null && (
|
|
<div className="mt-1 text-xs text-status-error">
|
|
{localize(
|
|
CONTROL_REASON_LABELS[control.reason] ??
|
|
'com_ui_subagent_control_reason_invalid_command',
|
|
)}
|
|
</div>
|
|
)}
|
|
{control.status === 'accepted' &&
|
|
control.controlId != null &&
|
|
onCancelControl != null && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-1 h-7 px-2 text-xs"
|
|
onClick={() => onCancelControl(control.controlId as string)}
|
|
>
|
|
{localize('com_ui_subagent_control_withdraw')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function SubagentActivityScrollSurface({
|
|
children,
|
|
padded = true,
|
|
}: {
|
|
children: React.ReactNode;
|
|
padded?: boolean;
|
|
}) {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const scrollButtonRef = useRef<HTMLDivElement>(null);
|
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
const [isSettled, setIsSettled] = useState(false);
|
|
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
|
|
|
|
useEffect(() => {
|
|
const scroll = scrollRef.current;
|
|
const content = contentRef.current;
|
|
if (scroll == null || content == null || typeof ResizeObserver === 'undefined') return;
|
|
const observer = new ResizeObserver(() => {
|
|
if (isAtBottom) scroll.scrollTop = scroll.scrollHeight;
|
|
});
|
|
observer.observe(content);
|
|
return () => observer.disconnect();
|
|
}, [isAtBottom]);
|
|
|
|
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
|
const element = event.currentTarget;
|
|
setIsAtBottom(
|
|
element.scrollHeight - element.scrollTop - element.clientHeight <= AT_BOTTOM_THRESHOLD_PX,
|
|
);
|
|
}, []);
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
scrollRef.current?.scrollTo({
|
|
top: scrollRef.current.scrollHeight,
|
|
behavior: 'smooth',
|
|
});
|
|
setIsAtBottom(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className={cn('min-h-0 flex-1 overflow-y-auto', padded && 'px-4 py-4')}
|
|
data-subagent-activity-scroll-surface
|
|
>
|
|
<div ref={contentRef}>{children}</div>
|
|
</div>
|
|
<CSSTransition
|
|
in={!isAtBottom && scrollButtonPreference}
|
|
timeout={{ enter: 300, exit: 180 }}
|
|
classNames="scroll-animation"
|
|
unmountOnExit={true}
|
|
appear={true}
|
|
nodeRef={scrollButtonRef}
|
|
onEntered={() => setIsSettled(true)}
|
|
onExit={() => setIsSettled(false)}
|
|
>
|
|
<ScrollToBottom
|
|
ref={scrollButtonRef}
|
|
scrollHandler={scrollToBottom}
|
|
interactive={isSettled}
|
|
/>
|
|
</CSSTransition>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const toContentPart = (item: ChildActivityItem): TMessageContentParts => {
|
|
if (item.type === 'writing') {
|
|
return {
|
|
type: ContentTypes.TEXT,
|
|
text: item.text,
|
|
...(item.phase == null ? {} : { phase: item.phase }),
|
|
} as TMessageContentParts;
|
|
}
|
|
if (item.type === 'reasoning') {
|
|
if (item.text == null || item.text === '') {
|
|
return {
|
|
type: ContentTypes.THINK,
|
|
think: '',
|
|
reasoning_unavailable: true,
|
|
...(item.label == null ? {} : { reasoning_label: item.label }),
|
|
} as TMessageContentParts;
|
|
}
|
|
return {
|
|
type: ContentTypes.THINK,
|
|
think: item.text,
|
|
...(item.label == null ? {} : { reasoning_label: item.label }),
|
|
} as TMessageContentParts;
|
|
}
|
|
if (item.type === 'activity_label') {
|
|
return {
|
|
type: ContentTypes.ACTIVITY_LABEL,
|
|
[ContentTypes.ACTIVITY_LABEL]: item.label,
|
|
...(item.labelType == null ? {} : { activity_label_type: item.labelType }),
|
|
...(item.toolCallIds == null ? {} : { tool_call_ids: item.toolCallIds }),
|
|
...(item.activityStartIndex == null ? {} : { activity_start_index: item.activityStartIndex }),
|
|
...(item.activityEndIndex == null ? {} : { activity_end_index: item.activityEndIndex }),
|
|
...(item.activityCount == null ? {} : { activity_count: item.activityCount }),
|
|
...(item.agentIds == null ? {} : { agent_ids: item.agentIds }),
|
|
...(item.status == null ? {} : { status: item.status }),
|
|
...(item.pending == null ? {} : { pending: item.pending }),
|
|
} as TMessageContentParts;
|
|
}
|
|
return {
|
|
type: ContentTypes.TOOL_CALL,
|
|
[ContentTypes.TOOL_CALL]: {
|
|
id: item.toolCallId,
|
|
name: item.name,
|
|
args: item.input ?? '',
|
|
output: item.output ?? '',
|
|
progress: item.status === 'running' ? 0.1 : 1,
|
|
...(item.status === 'running' ? {} : { runStepStatus: item.status }),
|
|
...(item.inputValidationError === true ? { inputValidationError: true } : {}),
|
|
...(item.approval == null ? {} : { approval: item.approval }),
|
|
},
|
|
} as TMessageContentParts;
|
|
};
|
|
|
|
function SubagentPrompt({ prompt }: { prompt: string }) {
|
|
const localize = useLocalize();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const headingId = useId();
|
|
const contentId = useId();
|
|
const toggleLabel = expanded ? localize('com_ui_collapse') : localize('com_ui_expand');
|
|
return (
|
|
<section
|
|
aria-labelledby={headingId}
|
|
className="mb-3 shrink-0 overflow-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary"
|
|
>
|
|
<div className="flex min-h-[2.75rem] items-center justify-between gap-3 border-b border-border-light px-3 py-2">
|
|
<h3 id={headingId} className="text-sm font-medium text-text-primary">
|
|
{localize('com_ui_prompt')}
|
|
</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
aria-controls={contentId}
|
|
aria-expanded={expanded}
|
|
aria-label={toggleLabel}
|
|
title={toggleLabel}
|
|
className="h-8 gap-1.5 rounded-md px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-tertiary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-text-primary"
|
|
>
|
|
{expanded ? <Minimize2 size={14} aria-hidden /> : <Maximize2 size={14} aria-hidden />}
|
|
<span className="hidden sm:inline">{toggleLabel}</span>
|
|
</Button>
|
|
</div>
|
|
<div
|
|
id={contentId}
|
|
className={cn(
|
|
'relative min-w-0 px-4 py-3',
|
|
expanded ? 'overflow-visible' : 'max-h-32 overflow-hidden',
|
|
)}
|
|
>
|
|
<div className="markdown prose prose-sm message-content light dark:prose-invert w-full max-w-none break-words text-text-primary">
|
|
<MarkdownLite content={prompt} codeExecution={false} />
|
|
</div>
|
|
{!expanded && (
|
|
<div
|
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-surface-secondary to-transparent"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean =>
|
|
activity.items.some(
|
|
(item) =>
|
|
(item.type === 'writing' && item.textTruncated === true) ||
|
|
(item.type === 'reasoning' && item.textTruncated === true) ||
|
|
(item.type === 'activity_label' && item.labelTruncated === true) ||
|
|
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
|
|
);
|
|
|
|
export function SubagentActivityContent({
|
|
activity,
|
|
activityId,
|
|
state = 'ready',
|
|
showPrompt = true,
|
|
showDetailTruncationNotice = true,
|
|
conversationId = null,
|
|
onCancelControl,
|
|
}: {
|
|
activity: ChildActivity;
|
|
activityId?: string;
|
|
state?: 'ready' | 'loading' | 'error';
|
|
showPrompt?: boolean;
|
|
showDetailTruncationNotice?: boolean;
|
|
conversationId?: string | null;
|
|
onCancelControl?: (controlId: string) => void;
|
|
}) {
|
|
const localize = useLocalize();
|
|
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
|
|
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
|
|
const activityDetailsTruncated = hasTruncatedActivityDetails(activity);
|
|
|
|
let body: React.ReactNode;
|
|
if (state === 'loading') {
|
|
body = (
|
|
<Container>
|
|
<EmptyText />
|
|
</Container>
|
|
);
|
|
} else if (state === 'error') {
|
|
body = (
|
|
<div className="rounded-lg border border-status-error-border bg-status-error-subtle p-3 text-sm text-status-error">
|
|
{localize('com_ui_subagent_thread_load_error')}
|
|
</div>
|
|
);
|
|
} else if (activity.items.length === 0) {
|
|
body = isSubmitting ? (
|
|
<Container>
|
|
<EmptyText />
|
|
</Container>
|
|
) : null;
|
|
} else {
|
|
body = (
|
|
<ContentParts
|
|
content={parts}
|
|
messageId={activityId ?? 'subagent-activity-panel'}
|
|
conversationId={conversationId}
|
|
isCreatedByUser={false}
|
|
isLast
|
|
isSubmitting={isSubmitting}
|
|
isLatestMessage={isSubmitting}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex max-w-full flex-col gap-0">
|
|
{showPrompt && activity.prompt != null && <SubagentPrompt prompt={activity.prompt} />}
|
|
<SubagentControlHistory
|
|
controls={activity.controls ?? []}
|
|
onCancelControl={onCancelControl}
|
|
/>
|
|
{activity.controlsTruncated === true && (
|
|
<div className="mb-3 text-xs italic text-text-secondary">
|
|
{localize('com_ui_subagent_control_history_truncated')}
|
|
</div>
|
|
)}
|
|
{showDetailTruncationNotice && activityDetailsTruncated && (
|
|
<div className="mb-3 text-xs italic text-text-secondary">
|
|
{localize('com_ui_subagent_activity_details_truncated')}
|
|
</div>
|
|
)}
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SubagentStatus({ activity }: { activity: ChildActivity }) {
|
|
const localize = useLocalize();
|
|
const StatusIcon = subagentStatusIcon(activity.status);
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1 text-xs text-text-secondary',
|
|
activity.status === 'failed' || activity.status === 'interrupted'
|
|
? 'text-status-error'
|
|
: '',
|
|
)}
|
|
aria-live="polite"
|
|
>
|
|
<StatusIcon size={13} aria-hidden />
|
|
<span>{localize(subagentStatusLabelKey(activity.status))}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SubagentActivity({
|
|
activity,
|
|
activityId,
|
|
state = 'ready',
|
|
embedded = false,
|
|
showPrompt = true,
|
|
onCancelControl,
|
|
}: {
|
|
activity: ChildActivity;
|
|
activityId?: string;
|
|
state?: 'ready' | 'loading' | 'error';
|
|
embedded?: boolean;
|
|
showPrompt?: boolean;
|
|
onCancelControl?: (controlId: string) => void;
|
|
}) {
|
|
const statusHeader = isAbnormalTerminalStatus(activity.status) ? (
|
|
<div className="shrink-0 border-b border-border-light px-4 py-2">
|
|
<SubagentStatus activity={activity} />
|
|
</div>
|
|
) : null;
|
|
const content = (
|
|
<SubagentActivityContent
|
|
activity={activity}
|
|
activityId={activityId}
|
|
state={state}
|
|
showPrompt={showPrompt}
|
|
onCancelControl={onCancelControl}
|
|
/>
|
|
);
|
|
|
|
if (embedded) {
|
|
return (
|
|
<section className="border-b border-border-light last:border-b-0" data-subagent-thread-turn>
|
|
{statusHeader}
|
|
<div className="px-4 py-4">{content}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
{statusHeader}
|
|
<SubagentActivityScrollSurface>{content}</SubagentActivityScrollSurface>
|
|
</div>
|
|
);
|
|
}
|