Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

画像にローディング表示を追加 #382

Merged
merged 4 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/web/src/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const ChatMessage: React.FC<Props> = (props) => {

useEffect(() => {
if (chatContent?.extraData) {
// ローディング表示にするために、画像の数だけ要素を用意して、undefinedを初期値として設定する
setSignedUrls(new Array(chatContent.extraData.length).fill(undefined));
Promise.all(
chatContent.extraData.map(async (file) => {
return await getDocDownloadSignedUrl(file.source.data);
Expand Down Expand Up @@ -104,7 +106,12 @@ const ChatMessage: React.FC<Props> = (props) => {
{signedUrls.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{signedUrls.map((url) => (
<ZoomUpImage key={url} src={url} size="m" />
<ZoomUpImage
key={url}
maekawataiki marked this conversation as resolved.
Show resolved Hide resolved
src={url}
size="m"
loading={!url}
/>
))}
</div>
)}
Expand Down
60 changes: 25 additions & 35 deletions packages/web/src/components/InputChatContent.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import ButtonSend from './ButtonSend';
import Textarea from './Textarea';
import ZoomUpImage from './ZoomUpImage';
import useChat from '../hooks/useChat';
import { useLocation } from 'react-router-dom';
import Button from './Button';
import { PiArrowsCounterClockwise, PiPaperclip } from 'react-icons/pi';
import useFileApi from '../hooks/useFileApi';
import {
PiArrowsCounterClockwise,
PiPaperclip,
PiSpinnerGap,
} from 'react-icons/pi';

import useFiles from '../hooks/useFiles';

type Props = {
Expand Down Expand Up @@ -35,11 +39,8 @@ type Props = {
const InputChatContent: React.FC<Props> = (props) => {
const { pathname } = useLocation();
const { loading: chatLoading, isEmpty } = useChat(pathname);
const [signedUrls, setSignedUrls] = useState<string[]>([]);
const { getDocDownloadSignedUrl } = useFileApi();
const { uploadedFiles, uploadFiles, deleteUploadedFile } = useFiles();

const [deleting, setDeleting] = useState(false);
const { uploadedFiles, uploadFiles, deleteUploadedFile, uploading } =
useFiles();

const onChangeFiles = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
Expand All @@ -51,28 +52,11 @@ const InputChatContent: React.FC<Props> = (props) => {

const deleteFile = useCallback(
(fileUrl: string) => {
setDeleting(true);
deleteUploadedFile(fileUrl).finally(() => {
setDeleting(false);
});
deleteUploadedFile(fileUrl);
},
[deleteUploadedFile]
);

useEffect(() => {
// アップロードされたファイルの URL が更新されたら Signed URL を更新
if (uploadedFiles) {
Promise.all(
uploadedFiles.map(async (file) => {
return await getDocDownloadSignedUrl(file.source.data);
})
).then((results) => setSignedUrls(results));
} else {
setSignedUrls([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uploadedFiles]);

const loading = useMemo(() => {
Comment on lines -62 to -75
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

アップロード直後は、ローカルでエンコードしたBASE64の画像を表示するようにしました(レイテンシ改善のため)

return props.loading === undefined ? chatLoading : props.loading;
}, [chatLoading, props.loading]);
Expand All @@ -91,16 +75,17 @@ const InputChatContent: React.FC<Props> = (props) => {
props.disableMarginBottom ? '' : 'mb-7'
}`}>
<div className="flex w-full flex-col">
{signedUrls.length > 0 && (
{uploadedFiles.length > 0 && (
<div className="m-2 flex flex-wrap gap-2">
{signedUrls.map((url) => (
{uploadedFiles.map((uploadedFile, idx) => (
<ZoomUpImage
key={url}
src={url}
key={idx}
src={uploadedFile.base64EncodedImage}
loading={uploadedFile.uploading}
size="s"
deleting={deleting}
deleting={uploadedFile.deleting}
onDelete={() => {
deleteFile(url);
deleteFile(uploadedFile.source.data);
}}
/>
))}
Expand All @@ -126,15 +111,20 @@ const InputChatContent: React.FC<Props> = (props) => {
multiple
value={[]}
/>
<div className="bg-aws-smile my-2 flex cursor-pointer items-center justify-center rounded-xl p-2 align-bottom text-xl text-white">
<PiPaperclip />
<div
className={`${uploading ? 'bg-gray-300' : 'bg-aws-smile cursor-pointer '} my-2 flex items-center justify-center rounded-xl p-2 align-bottom text-xl text-white`}>
{uploading ? (
<PiSpinnerGap className="animate-spin" />
) : (
<PiPaperclip />
)}
</div>
</label>
)}
<ButtonSend
className="m-2 align-bottom"
disabled={disabledSend}
loading={loading}
loading={loading || uploading}
onClick={props.onSend}
icon={props.sendIcon}
/>
Expand Down
14 changes: 10 additions & 4 deletions packages/web/src/components/ZoomUpImage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import { BaseProps } from '../@types/common';
import ButtonIcon from './ButtonIcon';
import { PiX } from 'react-icons/pi';
import { PiSpinnerGap, PiX } from 'react-icons/pi';

type Props = BaseProps & {
src: string;
src?: string;
loading?: boolean;
size: 's' | 'm';
deleting?: boolean;
onDelete?: () => void;
Expand All @@ -17,15 +18,20 @@ const ZoomUpImage: React.FC<Props> = (props) => {
<div className={props.className}>
<div className="group relative cursor-pointer">
<img
className={`border-aws-squid-ink/50 rounded border object-cover object-center ${props.size === 's' ? 'size-24' : 'size-32'}`}
className={`border-aws-squid-ink/50 bg-aws-squid-ink/20 rounded border object-cover object-center ${props.size === 's' ? 'size-24' : 'size-32'}`}
src={props.src}
onClick={() => {
setZoom(true);
}}
/>
{(props.loading || props.deleting) && (
<div className="bg-aws-squid-ink/20 absolute top-0 flex h-full w-full items-center justify-center rounded">
<PiSpinnerGap className="animate-spin text-4xl text-white" />
</div>
)}
{props.onDelete && (
<ButtonIcon
className={`${props.deleting ? '' : 'invisible'} absolute right-0 top-0 m-0.5 border bg-white text-xs group-hover:visible`}
className={`${props.deleting ? '' : 'group-hover:visible'} invisible absolute right-0 top-0 m-0.5 border bg-white text-xs `}
loading={props.deleting}
onClick={props.onDelete}>
<PiX />
Expand Down
96 changes: 61 additions & 35 deletions packages/web/src/hooks/useFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import useFileApi from './useFileApi';
import { ExtraData } from 'generative-ai-use-cases-jp';
import { produce } from 'immer';

type UploadedFileType = ExtraData & {
file: File;
base64EncodedImage?: string;
uploading: boolean;
deleting?: boolean;
};
maekawataiki marked this conversation as resolved.
Show resolved Hide resolved

const extractBaseURL = (url: string) => {
return url.split(/[?#]/)[0];
};

const useFilesState = create<{
loading: boolean;
uploadFiles: (files: File[]) => Promise<void>;
uploadedFiles: ExtraData[];
uploadedFiles: UploadedFileType[];
deleteUploadedFile: (fileUrl: string) => Promise<boolean>;
clear: () => void;
}>((set, get) => {
Expand All @@ -23,41 +28,57 @@ const useFilesState = create<{
};

const uploadFiles = async (files: File[]) => {
set(() => ({
loading: true,
const uploadedFiles: UploadedFileType[] = files.map((file) => ({
file,
uploading: true,
type: 'image',
source: {
type: 's3',
mediaType: file.type,
data: '',
},
}));

const fileUrls = files
? await Promise.all(
files.map(async (file) => {
const mediaFormat = file.name.split('.').pop() as string;

// 署名付き URL の取得
const signedUrlRes = await api.getSignedUrl({
mediaFormat: mediaFormat,
});
const signedUrl = signedUrlRes.data;
const fileUrl = extractBaseURL(signedUrl); // 署名付き url からクエリパラメータを除外

// ファイルのアップロード
await api.uploadFile(signedUrl, { file: file });
return {
type: 'image',
source: {
type: 's3',
mediaType: file.type,
data: fileUrl,
},
};
})
)
: [];

set(() => ({
uploadedFiles: produce(get().uploadedFiles, (draft) => {
draft.push(...fileUrls);
draft.push(...uploadedFiles);
}),
}));

get().uploadedFiles.forEach((uploadedFile, idx) => {
// 「画像アップロード => 署名付きURL取得 => 画像ダウンロード」だと、画像が画面に表示されるまでに時間がかかるため、
// 選択した画像をローカルでBASE64エンコーディングし、そのまま画面に表示する(UX改善のため)
const reader = new FileReader();
reader.readAsDataURL(uploadedFile.file);
reader.onload = () => {
set(() => ({
uploadedFiles: produce(get().uploadedFiles, (draft) => {
draft[idx].base64EncodedImage = reader.result?.toString();
}),
}));
};

const mediaFormat = uploadedFile.file.name.split('.').pop() as string;

// 署名付き URL の取得(並列実行させるために、await せずに実行)
api
.getSignedUrl({
mediaFormat: mediaFormat,
})
.then(async (signedUrlRes) => {
const signedUrl = signedUrlRes.data;
const fileUrl = extractBaseURL(signedUrl); // 署名付き url からクエリパラメータを除外
// ファイルのアップロード
api.uploadFile(signedUrl, { file: uploadedFile.file }).then(() => {
set({
uploadedFiles: produce(get().uploadedFiles, (draft) => {
draft[idx].uploading = false;
draft[idx].source.data = fileUrl;
}),
});
});
});
});
};

const deleteUploadedFile = async (fileUrl: string) => {
Expand All @@ -74,6 +95,12 @@ const useFilesState = create<{
const fileName = result?.groups?.fileName;

if (fileName) {
set({
uploadedFiles: produce(get().uploadedFiles, (draft) => {
draft[targetIndex].deleting = true;
}),
});

await api.deleteUploadedFile(fileName);

// 削除処理中に他の画像も削除された場合に、Indexがズレるため再取得する
Expand All @@ -91,7 +118,6 @@ const useFilesState = create<{
};

return {
loading: false,
clear,
uploadedFiles: [],
uploadFiles,
Expand All @@ -100,14 +126,14 @@ const useFilesState = create<{
});

const useFiles = () => {
const { loading, uploadFiles, clear, uploadedFiles, deleteUploadedFile } =
const { uploadFiles, clear, uploadedFiles, deleteUploadedFile } =
useFilesState();
return {
loading,
uploadFiles,
clear,
uploadedFiles,
deleteUploadedFile,
uploading: uploadedFiles.some((uploadedFile) => uploadedFile.uploading),
};
};
export default useFiles;
Loading