mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
📁 feat: Surface Stateful Workspace Downloads (#14984)
* feat: surface stateful workspace downloads * fix: sort workspace change imports * fix: reuse workspace button primitives * fix: hide collapsed workspace actions
This commit is contained in:
parent
a33b128c47
commit
e4d6bb71f9
10 changed files with 533 additions and 10 deletions
|
|
@ -7,6 +7,7 @@ jest.mock('nanoid', () => ({
|
|||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
writeAttachmentEvent: jest.fn(),
|
||||
GenerationJobManager: {
|
||||
emitChunk: jest.fn(),
|
||||
},
|
||||
|
|
@ -444,6 +445,7 @@ describe('createToolEndCallback', () => {
|
|||
name,
|
||||
toolName = 'execute_code',
|
||||
hostFileAuthoring = false,
|
||||
created,
|
||||
codeExecutionContext,
|
||||
}) {
|
||||
return {
|
||||
|
|
@ -452,6 +454,8 @@ describe('createToolEndCallback', () => {
|
|||
tool_call_id: toolCallId,
|
||||
artifact: {
|
||||
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
|
||||
...(created === undefined ? {} : { created }),
|
||||
path: name,
|
||||
session_id: 'sess-1',
|
||||
files: [{ id: fileId, name, session_id: 'sess-1' }],
|
||||
},
|
||||
|
|
@ -667,8 +671,17 @@ describe('createToolEndCallback', () => {
|
|||
conversationId: 'thread789',
|
||||
messageId: 'run-create',
|
||||
toolCallId: 'tool-create',
|
||||
status: 'ready',
|
||||
status: 'pending',
|
||||
},
|
||||
finalize: jest.fn().mockResolvedValue({
|
||||
file_id: 'fid-created',
|
||||
filename: 'created.txt',
|
||||
filepath: '/uploads/created.txt',
|
||||
type: 'text/plain',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'run-create',
|
||||
status: 'ready',
|
||||
}),
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
|
@ -680,6 +693,7 @@ describe('createToolEndCallback', () => {
|
|||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
hostFileAuthoring: true,
|
||||
created: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
|
|
@ -687,6 +701,7 @@ describe('createToolEndCallback', () => {
|
|||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(processCodeOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -699,7 +714,139 @@ describe('createToolEndCallback', () => {
|
|||
executionProfile: 'stateful',
|
||||
}),
|
||||
);
|
||||
expect(res.write).toHaveBeenCalledTimes(2);
|
||||
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toEqual({
|
||||
profile: 'stateful',
|
||||
operation: 'created',
|
||||
path: 'created.txt',
|
||||
});
|
||||
expect(parseSseAttachment(res.write.mock.calls[1]).workspaceChange).toEqual({
|
||||
profile: 'stateful',
|
||||
operation: 'created',
|
||||
path: 'created.txt',
|
||||
});
|
||||
await expect(artifactPromises[0]).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
workspaceChange: {
|
||||
profile: 'stateful',
|
||||
operation: 'created',
|
||||
path: 'created.txt',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark stateless file authoring outputs as stateful workspace changes', async () => {
|
||||
res.headersSent = true;
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-default',
|
||||
filename: 'default.txt',
|
||||
filepath: '/uploads/default.txt',
|
||||
type: 'text/plain',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'run-default',
|
||||
toolCallId: 'tool-default',
|
||||
status: 'ready',
|
||||
},
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-default',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-default',
|
||||
fileId: 'fid-default',
|
||||
name: 'default.txt',
|
||||
toolName: 'create_file',
|
||||
hostFileAuthoring: true,
|
||||
created: true,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-default.example.com',
|
||||
executionProfile: 'default',
|
||||
},
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
expect(parseSseAttachment(res.write.mock.calls[0]).workspaceChange).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves stateful workspace changes in Open Responses attachment events', async () => {
|
||||
const { writeAttachmentEvent } = require('@librechat/api');
|
||||
const { createResponsesToolEndCallback } = require('../callbacks');
|
||||
res.headersSent = true;
|
||||
res.writableEnded = false;
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-responses',
|
||||
filename: 'summary.csv',
|
||||
filepath: '/uploads/summary.csv',
|
||||
type: 'text/csv',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'run-responses',
|
||||
toolCallId: 'tool-responses',
|
||||
status: 'pending',
|
||||
},
|
||||
finalize: jest.fn().mockResolvedValue({
|
||||
file_id: 'fid-responses',
|
||||
filename: 'summary.csv',
|
||||
filepath: '/uploads/summary.csv',
|
||||
type: 'text/csv',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'run-responses',
|
||||
status: 'ready',
|
||||
}),
|
||||
});
|
||||
|
||||
const tracker = { nextSequence: jest.fn().mockReturnValueOnce(1).mockReturnValueOnce(2) };
|
||||
const toolEndCallback = createResponsesToolEndCallback({
|
||||
req,
|
||||
res,
|
||||
tracker,
|
||||
artifactPromises,
|
||||
});
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-responses',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-responses',
|
||||
fileId: 'fid-responses',
|
||||
name: 'summary.csv',
|
||||
toolName: 'edit_file',
|
||||
hostFileAuthoring: true,
|
||||
created: false,
|
||||
codeExecutionContext: {
|
||||
baseUrl: 'https://code-stateful.example.com',
|
||||
executionProfile: 'stateful',
|
||||
},
|
||||
});
|
||||
event.output.artifact.path = 'reports/summary.csv';
|
||||
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(writeAttachmentEvent).toHaveBeenCalledTimes(2);
|
||||
expect(writeAttachmentEvent.mock.calls[0][2].workspaceChange).toEqual({
|
||||
profile: 'stateful',
|
||||
operation: 'updated',
|
||||
path: 'reports/summary.csv',
|
||||
});
|
||||
expect(writeAttachmentEvent.mock.calls[1][2].workspaceChange).toEqual({
|
||||
profile: 'stateful',
|
||||
operation: 'updated',
|
||||
path: 'reports/summary.csv',
|
||||
});
|
||||
await expect(artifactPromises[0]).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
workspaceChange: {
|
||||
profile: 'stateful',
|
||||
operation: 'updated',
|
||||
path: 'reports/summary.csv',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,25 @@ function isCodeArtifactToolOutput(output) {
|
|||
return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact);
|
||||
}
|
||||
|
||||
function addStatefulWorkspaceChange(attachment, artifact, executionProfile) {
|
||||
if (!attachment || executionProfile !== 'stateful' || !isHostFileAuthoringArtifact(artifact)) {
|
||||
return attachment;
|
||||
}
|
||||
const path =
|
||||
typeof artifact.path === 'string' && artifact.path.length > 0
|
||||
? artifact.path
|
||||
: attachment.filename;
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
return attachment;
|
||||
}
|
||||
attachment.workspaceChange = {
|
||||
profile: 'stateful',
|
||||
operation: artifact.created === true ? 'created' : 'updated',
|
||||
path,
|
||||
};
|
||||
return attachment;
|
||||
}
|
||||
|
||||
class ModelEndHandler {
|
||||
/**
|
||||
* @param {Array<UsageMetadata>} collectedUsage
|
||||
|
|
@ -979,7 +998,11 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
|
|||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
});
|
||||
const fileMetadata = result?.file ?? null;
|
||||
const fileMetadata = addStatefulWorkspaceChange(
|
||||
result?.file ?? null,
|
||||
output.artifact,
|
||||
metadata.codeExecutionContext?.executionProfile,
|
||||
);
|
||||
const finalize = result?.finalize;
|
||||
if (!fileMetadata) {
|
||||
return null;
|
||||
|
|
@ -1027,6 +1050,9 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
|
|||
...updated,
|
||||
messageId: metadata.run_id,
|
||||
toolCallId,
|
||||
...(fileMetadata.workspaceChange
|
||||
? { workspaceChange: fileMetadata.workspaceChange }
|
||||
: {}),
|
||||
},
|
||||
jobCreatedAt,
|
||||
);
|
||||
|
|
@ -1303,7 +1329,11 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
|
||||
executionProfile: metadata.codeExecutionContext?.executionProfile,
|
||||
});
|
||||
const fileMetadata = result?.file ?? null;
|
||||
const fileMetadata = addStatefulWorkspaceChange(
|
||||
result?.file ?? null,
|
||||
output.artifact,
|
||||
metadata.codeExecutionContext?.executionProfile,
|
||||
);
|
||||
const finalize = result?.finalize;
|
||||
if (!fileMetadata) {
|
||||
return null;
|
||||
|
|
@ -1336,7 +1366,12 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
writeResponsesAttachment(
|
||||
res,
|
||||
tracker,
|
||||
buildResponsesAttachment(updated, toolCallId),
|
||||
buildResponsesAttachment(
|
||||
fileMetadata.workspaceChange
|
||||
? { ...updated, workspaceChange: fileMetadata.workspaceChange }
|
||||
: updated,
|
||||
toolCallId,
|
||||
),
|
||||
metadata,
|
||||
);
|
||||
},
|
||||
|
|
@ -1371,6 +1406,7 @@ function buildResponsesAttachment(fileMetadata, toolCallId) {
|
|||
textFormat: fileMetadata.textFormat ?? null,
|
||||
status: fileMetadata.status,
|
||||
previewError: fileMetadata.previewError,
|
||||
workspaceChange: fileMetadata.workspaceChange,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -367,7 +367,10 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
req,
|
||||
{ baseUrl, executionProfile },
|
||||
);
|
||||
res.set(response.headers);
|
||||
res.setHeader('Content-Disposition', 'attachment');
|
||||
res.setHeader('Content-Type', 'application/octet-stream');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
/* `logAxiosError` redacts buffer/stream response bodies — without
|
||||
|
|
|
|||
|
|
@ -1097,7 +1097,10 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
describe('GET /files/code/download/:session_id/:fileId', () => {
|
||||
it('routes a persisted stateful fallback through the stateful Code API', async () => {
|
||||
const getDownloadStream = jest.fn().mockResolvedValue({
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
'set-cookie': 'internal-service-cookie=secret',
|
||||
},
|
||||
data: Readable.from(['stateful output']),
|
||||
});
|
||||
getStrategyFunctions.mockReturnValue({ getDownloadStream });
|
||||
|
|
@ -1111,7 +1114,12 @@ describe('File Routes - Delete with Agent Access', () => {
|
|||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('stateful output');
|
||||
expect(response.body.toString()).toBe('stateful output');
|
||||
expect(response.headers['content-disposition']).toBe('attachment');
|
||||
expect(response.headers['content-type']).toBe('application/octet-stream');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
expect(response.headers['set-cookie']).toBeUndefined();
|
||||
expect(getDownloadStream).toHaveBeenCalledWith(
|
||||
`${sessionId}/${codeFileId}`,
|
||||
{ kind: 'user', id: otherUserId.toString() },
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
import type { ReactNode, ReactElement } from 'react';
|
||||
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
|
||||
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
|
||||
import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges';
|
||||
import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels';
|
||||
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
|
||||
import MemoryArtifacts, { hasMemoryArtifacts } from './MemoryArtifacts';
|
||||
|
|
@ -163,6 +164,8 @@ type ContentPartsProps = {
|
|||
| undefined;
|
||||
/** Internal recursion guard for nested phase segments. */
|
||||
nestedActivityPhase?: boolean;
|
||||
/** Internal signal that the parent already removed message-level workspace attachments. */
|
||||
workspaceAttachmentsPartitioned?: boolean;
|
||||
/** Absolute transcript index represented by `content[0]` in a phase slice. */
|
||||
contentIndexOffset?: number;
|
||||
/** Absolute transcript index for each compacted sparse segment entry. */
|
||||
|
|
@ -197,12 +200,20 @@ const ContentParts = memo(function ContentParts({
|
|||
isLatestMessage,
|
||||
createdAt,
|
||||
nestedActivityPhase = false,
|
||||
workspaceAttachmentsPartitioned = false,
|
||||
contentIndexOffset = 0,
|
||||
contentIndices,
|
||||
resumeAuthors,
|
||||
toolGroupExpansionState,
|
||||
}: ContentPartsProps) {
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
const { inlineAttachments, workspaceChanges } = useMemo(
|
||||
() =>
|
||||
workspaceAttachmentsPartitioned
|
||||
? { inlineAttachments: attachments ?? [], workspaceChanges: [] }
|
||||
: partitionWorkspaceChanges(attachments),
|
||||
[attachments, workspaceAttachmentsPartitioned],
|
||||
);
|
||||
const attachmentMap = useMemo(() => mapAttachments(inlineAttachments), [inlineAttachments]);
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
const localToolGroupExpansionRef = useRef(new Map<string, ToolCallGroupExpansionState>());
|
||||
const expansionState = toolGroupExpansionState ?? localToolGroupExpansionRef.current;
|
||||
|
|
@ -459,7 +470,7 @@ const ContentParts = memo(function ContentParts({
|
|||
);
|
||||
|
||||
// Early return: no content to render AND no pending skill cards
|
||||
if (!content && !hasPendingSkills) {
|
||||
if (!content && !hasPendingSkills && workspaceChanges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -479,6 +490,7 @@ const ContentParts = memo(function ContentParts({
|
|||
setSiblingIdx={setSiblingIdx}
|
||||
renderReadOnlyPart={(part, idx, isLastPart) => renderPart(part, idx, isLastPart)}
|
||||
/>
|
||||
<WorkspaceChanges attachments={workspaceChanges} />
|
||||
</SearchContext.Provider>
|
||||
</ApprovalProvider>
|
||||
);
|
||||
|
|
@ -502,13 +514,14 @@ const ContentParts = memo(function ContentParts({
|
|||
createdAt={createdAt}
|
||||
authorHeader={authorHeader}
|
||||
conversationId={conversationId}
|
||||
attachments={attachments}
|
||||
attachments={inlineAttachments}
|
||||
searchResults={searchResults}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={isLast && segmentIndices.includes(globalLastContentIdx)}
|
||||
isSubmitting={isSubmitting}
|
||||
isLatestMessage={isLatestMessage}
|
||||
nestedActivityPhase
|
||||
workspaceAttachmentsPartitioned
|
||||
contentIndexOffset={segmentStartIndex}
|
||||
contentIndices={segmentIndices}
|
||||
resumeAuthors={postSteerAuthors}
|
||||
|
|
@ -559,6 +572,7 @@ const ContentParts = memo(function ContentParts({
|
|||
)
|
||||
),
|
||||
)}
|
||||
<WorkspaceChanges attachments={workspaceChanges} />
|
||||
</SearchContext.Provider>
|
||||
</ApprovalProvider>
|
||||
);
|
||||
|
|
@ -598,6 +612,7 @@ const ContentParts = memo(function ContentParts({
|
|||
contentIndexOffset={contentIndexOffset}
|
||||
contentIndices={contentIndices}
|
||||
/>
|
||||
{!nestedActivityPhase && <WorkspaceChanges attachments={workspaceChanges} />}
|
||||
</>
|
||||
);
|
||||
return nestedActivityPhase ? (
|
||||
|
|
@ -660,6 +675,7 @@ const ContentParts = memo(function ContentParts({
|
|||
);
|
||||
return nodes;
|
||||
})}
|
||||
{!nestedActivityPhase && <WorkspaceChanges attachments={workspaceChanges} />}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
if (nestedActivityPhase) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import { memo, useId, useMemo, useState } from 'react';
|
||||
import { Button, IconButton } from '@librechat/client';
|
||||
import { ChevronDown, Download, Files } from 'lucide-react';
|
||||
import type {
|
||||
TAttachment,
|
||||
TFile,
|
||||
WorkspaceChange as WorkspaceChangeMetadata,
|
||||
} from 'librechat-data-provider';
|
||||
import { useExpandCollapse, useLocalize } from '~/hooks';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type StatefulWorkspaceAttachment = TAttachment & {
|
||||
workspaceChange: WorkspaceChangeMetadata;
|
||||
};
|
||||
|
||||
export function partitionWorkspaceChanges(attachments?: TAttachment[]): {
|
||||
inlineAttachments: TAttachment[];
|
||||
workspaceChanges: StatefulWorkspaceAttachment[];
|
||||
} {
|
||||
const inlineAttachments: TAttachment[] = [];
|
||||
const changesByFile = new Map<string, StatefulWorkspaceAttachment>();
|
||||
|
||||
for (const attachment of attachments ?? []) {
|
||||
const change = attachment.workspaceChange;
|
||||
if (change?.profile !== 'stateful' || !attachment.filepath) {
|
||||
inlineAttachments.push(attachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = attachment as Partial<TFile> & { agentId?: string };
|
||||
const key = file.file_id ?? `${file.agentId ?? ''}:${change.path}`;
|
||||
changesByFile.delete(key);
|
||||
changesByFile.set(key, attachment as StatefulWorkspaceAttachment);
|
||||
}
|
||||
|
||||
return { inlineAttachments, workspaceChanges: Array.from(changesByFile.values()) };
|
||||
}
|
||||
|
||||
const WorkspaceChange = memo(({ attachment }: { attachment: StatefulWorkspaceAttachment }) => {
|
||||
const localize = useLocalize();
|
||||
const file = attachment as TFile;
|
||||
const path = attachment.workspaceChange.path;
|
||||
const filename = path.split('/').pop() || path;
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath ?? '',
|
||||
filename,
|
||||
file_id: file.file_id,
|
||||
user: file.user,
|
||||
source: file.source,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-surface-secondary px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text-primary" title={filename}>
|
||||
{filename}
|
||||
</div>
|
||||
{path !== filename && (
|
||||
<div className="truncate text-xs text-text-secondary" title={path}>
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
onClick={(event) => void handleDownload(event)}
|
||||
label={`${localize('com_ui_download')} ${filename}`}
|
||||
title={localize('com_ui_download')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
shape="square"
|
||||
className="text-text-secondary"
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
WorkspaceChange.displayName = 'WorkspaceChange';
|
||||
|
||||
export default function WorkspaceChanges({
|
||||
attachments,
|
||||
}: {
|
||||
attachments: StatefulWorkspaceAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const panelId = useId();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { style, ref } = useExpandCollapse(isExpanded);
|
||||
const count = attachments.length;
|
||||
const countLabel = localize(count === 1 ? 'com_ui_one_file_changed' : 'com_ui_n_files_changed', {
|
||||
0: String(count),
|
||||
});
|
||||
const summary = useMemo(
|
||||
() => attachments.map((attachment) => attachment.workspaceChange.path).join(', '),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 max-w-xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={panelId}
|
||||
aria-label={`${localize('com_ui_workspace_changes')}: ${countLabel}`}
|
||||
onClick={() => setIsExpanded((previous) => !previous)}
|
||||
className="h-auto max-w-full justify-start py-1 pl-0 pr-2 font-normal text-text-secondary"
|
||||
>
|
||||
<Files className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0 font-medium">{localize('com_ui_workspace_changes')}</span>
|
||||
<span className="min-w-0 truncate text-xs" title={summary}>
|
||||
{'— '}
|
||||
{countLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-4 shrink-0 transition-transform duration-200 ease-out',
|
||||
isExpanded && 'rotate-180',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
<div id={panelId} style={style} inert={!isExpanded ? '' : undefined}>
|
||||
<div className="overflow-hidden" ref={ref} aria-hidden={!isExpanded}>
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{attachments.map((attachment) => (
|
||||
<WorkspaceChange
|
||||
key={`${attachment.filepath}:${attachment.workspaceChange.path}`}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import React from 'react';
|
||||
import { FileSources } from 'librechat-data-provider';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import WorkspaceChanges, { partitionWorkspaceChanges } from '../WorkspaceChanges';
|
||||
|
||||
const mockHandleDownload = jest.fn();
|
||||
|
||||
jest.mock('../LogLink', () => ({
|
||||
useAttachmentLink: () => ({ handleDownload: mockHandleDownload }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useExpandCollapse: () => ({ style: {}, ref: { current: null } }),
|
||||
useLocalize: () => (key: string, values?: Record<number, string>) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_ui_download: 'Download',
|
||||
com_ui_n_files_changed: `${values?.[0]} files changed`,
|
||||
com_ui_one_file_changed: '1 file changed',
|
||||
com_ui_workspace_changes: 'Workspace changes',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: Array<string | false | undefined>) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
function makeAttachment({
|
||||
fileId,
|
||||
path,
|
||||
profile = 'stateful',
|
||||
filepath,
|
||||
}: {
|
||||
fileId: string;
|
||||
path: string;
|
||||
profile?: 'stateful' | 'default';
|
||||
filepath?: string;
|
||||
}): TAttachment {
|
||||
return {
|
||||
file_id: fileId,
|
||||
filename: path,
|
||||
filepath: filepath ?? `/uploads/${fileId}`,
|
||||
source: FileSources.local,
|
||||
user: 'user-1',
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'message-1',
|
||||
toolCallId: `tool-${fileId}`,
|
||||
workspaceChange: {
|
||||
profile,
|
||||
operation: 'updated',
|
||||
path,
|
||||
},
|
||||
} as TAttachment;
|
||||
}
|
||||
|
||||
describe('WorkspaceChanges', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleDownload.mockReset();
|
||||
});
|
||||
|
||||
it('partitions only downloadable stateful changes and keeps the latest file entry', () => {
|
||||
const first = makeAttachment({ fileId: 'shared', path: 'reports/result.csv' });
|
||||
const latest = {
|
||||
...makeAttachment({ fileId: 'shared', path: 'reports/result.csv' }),
|
||||
filepath: '/uploads/latest',
|
||||
} as TAttachment;
|
||||
const stateless = makeAttachment({
|
||||
fileId: 'default',
|
||||
path: 'default.txt',
|
||||
profile: 'default',
|
||||
});
|
||||
const unavailable = makeAttachment({ fileId: 'missing', path: 'missing.txt' });
|
||||
unavailable.filepath = '';
|
||||
|
||||
const result = partitionWorkspaceChanges([first, stateless, unavailable, latest]);
|
||||
|
||||
expect(result.inlineAttachments).toEqual([stateless, unavailable]);
|
||||
expect(result.workspaceChanges).toEqual([latest]);
|
||||
});
|
||||
|
||||
it('renders one collapsed row and downloads through the existing attachment handler', () => {
|
||||
const changes = partitionWorkspaceChanges([
|
||||
makeAttachment({ fileId: 'one', path: 'reports/summary.csv' }),
|
||||
makeAttachment({ fileId: 'two', path: 'notes.txt' }),
|
||||
]).workspaceChanges;
|
||||
|
||||
render(<WorkspaceChanges attachments={changes} />);
|
||||
|
||||
const toggle = screen.getByRole('button', {
|
||||
name: 'Workspace changes: 2 files changed',
|
||||
});
|
||||
const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? '');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(panel).toHaveAttribute('inert');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(panel).not.toHaveAttribute('inert');
|
||||
expect(screen.getByText('summary.csv')).toBeInTheDocument();
|
||||
expect(screen.getByText('reports/summary.csv')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download summary.csv' }));
|
||||
expect(mockHandleDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,18 @@ jest.mock('../Parts/PendingSkillCall', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
jest.mock('../Parts/WorkspaceChanges', () => ({
|
||||
__esModule: true,
|
||||
default: ({ attachments }: { attachments: TAttachment[] }) =>
|
||||
attachments.length > 0 ? (
|
||||
<div data-testid="workspace-changes" data-count={attachments.length} />
|
||||
) : null,
|
||||
partitionWorkspaceChanges: (attachments?: TAttachment[]) => ({
|
||||
inlineAttachments: (attachments ?? []).filter((attachment) => !attachment.workspaceChange),
|
||||
workspaceChanges: (attachments ?? []).filter((attachment) => attachment.workspaceChange),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../ToolCallGroup', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
|
|
@ -144,6 +156,48 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
describe('ContentParts — interim skill cards', () => {
|
||||
it('renders stateful workspace changes once at message level', () => {
|
||||
const content: TMessageContentParts[] = [
|
||||
{ type: ContentTypes.TEXT, text: 'done' } as TMessageContentParts,
|
||||
];
|
||||
const attachment = {
|
||||
filename: 'report.csv',
|
||||
filepath: '/uploads/report.csv',
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'msg-1',
|
||||
toolCallId: 'tool-1',
|
||||
workspaceChange: {
|
||||
profile: 'stateful',
|
||||
operation: 'created',
|
||||
path: 'report.csv',
|
||||
},
|
||||
} as TAttachment;
|
||||
|
||||
render(<ContentParts {...baseProps} content={content} attachments={[attachment]} />);
|
||||
|
||||
expect(screen.getAllByTestId('workspace-changes')).toHaveLength(1);
|
||||
expect(screen.getByTestId('workspace-changes')).toHaveAttribute('data-count', '1');
|
||||
});
|
||||
|
||||
it('renders stateful workspace changes when the assistant message has no content yet', () => {
|
||||
const attachment = {
|
||||
filename: 'report.csv',
|
||||
filepath: '/uploads/report.csv',
|
||||
conversationId: 'conversation-1',
|
||||
messageId: 'msg-1',
|
||||
toolCallId: 'tool-1',
|
||||
workspaceChange: {
|
||||
profile: 'stateful',
|
||||
operation: 'created',
|
||||
path: 'report.csv',
|
||||
},
|
||||
} as TAttachment;
|
||||
|
||||
render(<ContentParts {...baseProps} content={undefined} attachments={[attachment]} />);
|
||||
|
||||
expect(screen.getByTestId('workspace-changes')).toHaveAttribute('data-count', '1');
|
||||
});
|
||||
|
||||
it('renders a PendingSkillCall per manual skill on assistant messages', () => {
|
||||
render(<ContentParts {...baseProps} manualSkills={['brand-guidelines', 'pptx']} />);
|
||||
const cards = screen.getAllByTestId('pending-skill-call');
|
||||
|
|
|
|||
|
|
@ -1595,6 +1595,7 @@
|
|||
"com_ui_my_prompts": "My Prompts",
|
||||
"com_ui_my_skills": "My Skills",
|
||||
"com_ui_n_files": "{{0}} files",
|
||||
"com_ui_n_files_changed": "{{0}} files changed",
|
||||
"com_ui_name": "Name",
|
||||
"com_ui_name_sort": "Sort by Name",
|
||||
"com_ui_navigate_results": "Navigate results",
|
||||
|
|
@ -1658,6 +1659,7 @@
|
|||
"com_ui_offline": "Offline",
|
||||
"com_ui_omitted": "Omitted",
|
||||
"com_ui_on": "On",
|
||||
"com_ui_one_file_changed": "1 file changed",
|
||||
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
|
||||
"com_ui_open_artifact": "Open artifact",
|
||||
"com_ui_open_as_artifact": "Open as artifact",
|
||||
|
|
@ -2294,6 +2296,7 @@
|
|||
"com_ui_web_searched": "Searched the web",
|
||||
"com_ui_web_searching": "Searching the web",
|
||||
"com_ui_web_searching_again": "Searching the web again",
|
||||
"com_ui_workspace_changes": "Workspace changes",
|
||||
"com_ui_write": "Writing",
|
||||
"com_ui_writing_command": "Writing command",
|
||||
"com_ui_x_selected": "{{0}} selected",
|
||||
|
|
|
|||
|
|
@ -895,10 +895,17 @@ export type UIResource = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type WorkspaceChange = {
|
||||
profile: 'stateful';
|
||||
operation: 'created' | 'updated';
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type TAttachmentMetadata = {
|
||||
type?: Tools;
|
||||
messageId: string;
|
||||
toolCallId: string;
|
||||
workspaceChange?: WorkspaceChange;
|
||||
[Tools.memory]?: MemoryArtifact;
|
||||
[Tools.ui_resources]?: UIResource[];
|
||||
[Tools.web_search]?: SearchResultData;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue