mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-07 02:58:50 +01:00
⬇️ feat: Assistant File Downloads (#2234)
* WIP: basic route for file downloads and file strategy for generating readablestream to pipe as res * chore(DALLE3): add typing for OpenAI client * chore: add `CONSOLE_JSON` notes to dotenv.md * WIP: first pass OpenAI Assistants File Output handling * feat: first pass assistants output file download from openai * chore: yml vs. yaml variation to .gitignore for `librechat.yml` * refactor(retrieveAndProcessFile): remove redundancies * fix(syncMessages): explicit sort of apiMessages to fix message order on abort * chore: add logs for warnings and errors, show toast on frontend * chore: add logger where console was still being used
This commit is contained in:
parent
7945fea0f9
commit
a00756c469
27 changed files with 555 additions and 248 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { memo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import remarkMath from 'remark-math';
|
||||
|
|
@ -9,9 +9,11 @@ import ReactMarkdown from 'react-markdown';
|
|||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { PluggableList } from 'unified';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import { cn, langSubset, validateIframe, processLaTeX } from '~/utils';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
import { useChatContext, useToastContext } from '~/Providers';
|
||||
import { useFileDownload } from '~/data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import store from '~/store';
|
||||
|
||||
type TCodeProps = {
|
||||
|
|
@ -37,6 +39,70 @@ export const code = memo(({ inline, className, children }: TCodeProps) => {
|
|||
}
|
||||
});
|
||||
|
||||
export const a = memo(({ href, children }: { href: string; children: React.ReactNode }) => {
|
||||
const user = useRecoilValue(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
const { filepath, filename } = useMemo(() => {
|
||||
const pattern = new RegExp(`(?:files|outputs)/${user?.id}/([^\\s]+)`);
|
||||
const match = href.match(pattern);
|
||||
if (match && match[0]) {
|
||||
const path = match[0];
|
||||
const name = path.split('/').pop();
|
||||
return { filepath: path, filename: name };
|
||||
}
|
||||
return { filepath: '', filename: '' };
|
||||
}, [user?.id, href]);
|
||||
|
||||
const { refetch: downloadFile } = useFileDownload(user?.id ?? '', filepath);
|
||||
const props: { target?: string; onClick?: React.MouseEventHandler } = { target: '_new' };
|
||||
|
||||
if (!filepath || !filename) {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDownload = async (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const stream = await downloadFile();
|
||||
if (!stream.data) {
|
||||
console.error('Error downloading file: No data found');
|
||||
showToast({
|
||||
status: 'error',
|
||||
message: localize('com_ui_download_error'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = stream.data;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(stream.data);
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
props.onClick = handleDownload;
|
||||
props.target = '_blank';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={filepath.startsWith('files/') ? `/api/${filepath}` : `/api/files/${filepath}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const p = memo(({ children }: { children: React.ReactNode }) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
|
@ -98,6 +164,7 @@ const Markdown = memo(({ content, message, showCursor }: TContentProps) => {
|
|||
components={
|
||||
{
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import ReactMarkdown from 'react-markdown';
|
|||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import { langSubset } from '~/utils';
|
||||
import { code, p } from './Markdown';
|
||||
import { code, a, p } from './Markdown';
|
||||
|
||||
const MarkdownLite = memo(({ content = '' }: { content?: string }) => {
|
||||
const rehypePlugins: PluggableList = [
|
||||
|
|
@ -30,6 +30,7 @@ const MarkdownLite = memo(({ content = '' }: { content?: string }) => {
|
|||
components={
|
||||
{
|
||||
code,
|
||||
a,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
|
|
|
|||
|
|
@ -324,3 +324,21 @@ export const useGetAssistantDocsQuery = (
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useFileDownload = (userId: string, filepath: string): QueryObserverResult<string> => {
|
||||
return useQuery(
|
||||
[QueryKeys.fileDownload, filepath],
|
||||
async () => {
|
||||
if (!userId) {
|
||||
console.warn('No user ID provided for file download');
|
||||
}
|
||||
const blob = await dataService.getFileDownload(userId, filepath);
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
return downloadUrl;
|
||||
},
|
||||
{
|
||||
enabled: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type {
|
||||
TSubmission,
|
||||
Text,
|
||||
TMessage,
|
||||
TContentData,
|
||||
TSubmission,
|
||||
ContentPart,
|
||||
TContentData,
|
||||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
|
@ -46,9 +47,9 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
|
|||
}
|
||||
|
||||
// TODO: handle streaming for non-text
|
||||
const part: ContentPart = data[ContentTypes.TEXT]
|
||||
? { value: data[ContentTypes.TEXT] }
|
||||
: data[type];
|
||||
const textPart: Text | string = data[ContentTypes.TEXT];
|
||||
const part: ContentPart =
|
||||
textPart && typeof textPart === 'string' ? { value: textPart } : data[type];
|
||||
|
||||
/* spreading the content array to avoid mutation */
|
||||
response.content = [...(response.content ?? [])];
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export default {
|
|||
com_assistants_update_error: 'There was an error updating your assistant.',
|
||||
com_assistants_create_success: 'Successfully created',
|
||||
com_assistants_create_error: 'There was an error creating your assistant.',
|
||||
com_ui_download_error: 'Error downloading file. The file may have been deleted.',
|
||||
com_ui_attach_error_type: 'Unsupported file type for endpoint:',
|
||||
com_ui_attach_error_size: 'File size limit exceeded for endpoint:',
|
||||
com_ui_attach_error:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue