Skip to content

Commit

Permalink
Merge pull request #12 from hatsu38/feature/pdfAttach-asSoonAs-chatRe…
Browse files Browse the repository at this point in the history
…quest

ファイルを添付したらすぐにAIに要約のリクエストを送る + 本文と要約を横並びに表示にする
  • Loading branch information
hatsu38 committed Apr 27, 2023
2 parents 5aefa58 + 39a5e0c commit 5856fc7
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 101 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { FC } from "react";

type PropsType = {
className?: string;
};

export const DotLoadingAnimation: FC<PropsType> = ({
className = "",
}: PropsType) => {
return (
<div className={`flex items-center space-x-4 ${className}`}>
<span className="w-1 h-1 bg-primary-800 rounded-full animate-ping" />
<span className="w-1 h-1 bg-primary-800 rounded-full animate-ping" />
<span className="w-1 h-1 bg-primary-800 rounded-full animate-ping" />
</div>
);
};
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { DropzoneFileField } from "./elements/DropzoneFileField/DropzoneFileField";
export { Icon } from "./elements/Icon/Icon";
export { Button } from "./elements/Button/Button";
export { DotLoadingAnimation } from "./elements/DotLoadingAnimation/DotLoadingAnimation";
67 changes: 11 additions & 56 deletions src/features/home/components/HomeLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,11 @@
import { useEffect } from "react";
import { DropzoneFileField, Icon } from "@keiyomi/components";
import { useFile } from "@keiyomi/hooks";

import { Button, DropzoneFileField, Icon } from "@keiyomi/components";
import { useContractSummaryRequest, SectionType } from "@keiyomi/features/home";
import { useFile, usePdfLoad, useArray } from "@keiyomi/hooks";

type SummarySectionType = SectionType & {
sectionSummary: string;
};
import { SectionsBlock } from "./SectionsBlock";

export const HomeLayout = () => {
const { file, handleDropFile, fileUrl } = useFile();
const { pdfHtml, sections, loadPdfUrl } = usePdfLoad();
const { items, unshiftItem } = useArray<SummarySectionType>();
const { doSummaryRequest, isChatRequesting } = useContractSummaryRequest("");

useEffect(() => {
if (fileUrl) loadPdfUrl(fileUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file, loadPdfUrl]);

const handleAiRequest = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
sections.map((section) => {
doSummaryRequest({
message: `${section.sectionTitle}\n${section.sectionContent}`,
onSuccess: (data) => {
unshiftItem({ ...section, sectionSummary: data.chatMessage.content });
},
});
});
};
const { file, handleDropFile, sections, summarySections, isChatRequesting } =
useFile();

return (
<main
Expand Down Expand Up @@ -60,34 +36,13 @@ export const HomeLayout = () => {
</p>
</div>
</DropzoneFileField>
{!!items.length && (
<div className="mt-8 bg-white px-10 py-8 rounded">
{items
.sort((a, b) => (a.id < b.id ? -1 : 1))
.map((item, index) => (
<section className="pt-10" key={`${item.sectionId}-${index}`}>
<h2 className="font-bold">{item.sectionTitle}</h2>
<p className="text-sm whitespace-pre-wrap ">
{item.sectionSummary}
</p>
</section>
))}
</div>
)}
{file && (
<div className="mt-8">
<div className="text-center">
<Button
text="契約書を要約"
onClick={handleAiRequest}
color="primary"
variant="outlined"
disabled={isChatRequesting}
/>
</div>
<div
className="mt-8 bg-white shadow max-h-[60vh] overflow-y-auto mx-auto border border-solid border-gray-200 rounded p-2 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: pdfHtml }}
<div className="mt-8 grid grid-cols-2 gap-x-4">
<SectionsBlock title="本文" isLoading={false} sections={sections} />
<SectionsBlock
title="要約"
isLoading={isChatRequesting}
sections={summarySections}
/>
</div>
)}
Expand Down
46 changes: 46 additions & 0 deletions src/features/home/components/SectionsBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FC } from "react";

import { DotLoadingAnimation } from "@keiyomi/components";
import { SectionType, SummarySectionType } from "@keiyomi/features/home";

type PropsType = {
isLoading: boolean;
title: string;
sections: SectionType[] | SummarySectionType[];
className?: string;
};

export const SectionsBlock: FC<PropsType> = ({
isLoading,
title,
sections,
className = "",
}) => {
const array = ["🤖", "🦾", "⚒️", "⏳", "🏋️"];
const randomIndex = Math.floor(Math.random() * array.length);
const randomValue = array[randomIndex];
return (
<div
className={`shadow-lg w-full bg-white px-10 py-8 rounded-lg ${className}`}
>
<h3 className="text-center font-bold text-xl">{title}</h3>
{isLoading ? (
<div className="mt-8 text-center text-sm text-gray-600 space-y-3">
<span className="">AIによる要約中です{randomValue}</span>
<DotLoadingAnimation className="justify-center" />
</div>
) : (
sections.map((item, index) => (
<section className="pt-10" key={`${item.sectionId}-${index}`}>
<h2 className="text-gray-600 font-bold">{item.sectionTitle}</h2>
<p className="text-sm whitespace-pre-wrap">
{"sectionSummary" in item
? item.sectionSummary
: item.sectionContent}
</p>
</section>
))
)}
</div>
);
};
9 changes: 7 additions & 2 deletions src/features/home/hooks/useContractSummaryRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ type ReturnType = {
isChatRequesting: boolean;
};

const systemMessage = `
* あなたは与えられたテキストを10歳の子どもにもわかる言葉で説明してください
* 子供たちが理解しやすいような簡単な短い日本語で話してください
* 敬語ではなく話し言葉を使ってください
`;

export const useContractSummaryRequest = (
currentEmployeeId: string
): ReturnType => {
Expand All @@ -19,8 +25,7 @@ export const useContractSummaryRequest = (
const doSummaryRequest = ({ message, onSuccess }: ApiRequestType) => {
openAiRequest({
prompt: message,
systemMessage:
"あなたは与えられたテキストを10歳の子どもにもわかる言葉で説明するAIです。",
systemMessage,
onSuccess,
});
};
Expand Down
17 changes: 16 additions & 1 deletion src/hooks/useFile.ts → src/features/home/hooks/useFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,26 @@ import { useState } from "react";
import { FileRejection } from "react-dropzone";
import { toast } from "react-toastify";

import { SectionType, SummarySectionType } from "@keiyomi/features/home";
import { usePdfLoad } from "@keiyomi/hooks";

type ReturnType = {
file: File | undefined;
setFile: (value: File | undefined) => void;
fileUrl: string | undefined;
pdfHtml: string;
sections: SectionType[];
summarySections: SummarySectionType[];
isChatRequesting: boolean;
handleDropFile: (
acceptedFiles: File[],
fileRejections: FileRejection[]
) => void;
};

export const useFile = (): ReturnType => {
const { pdfHtml, sections, summarySections, isChatRequesting, loadPdfUrl } =
usePdfLoad();
const [file, setFile] = useState<File | undefined>();

const fileUrl = file ? URL.createObjectURL(file) : undefined;
Expand All @@ -28,12 +37,18 @@ export const useFile = (): ReturnType => {
});
return;
}
setFile(acceptedFiles[0]);
const newFile = acceptedFiles[0];
setFile(newFile);
loadPdfUrl(URL.createObjectURL(newFile));
};

return {
file,
fileUrl,
pdfHtml,
sections,
summarySections,
isChatRequesting,
setFile,
handleDropFile,
};
Expand Down
66 changes: 51 additions & 15 deletions src/features/home/hooks/useHtmlParse.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useCallback, useState } from "react";

import { useArray } from "@keiyomi/hooks";

import { useContractSummaryRequest } from "./useContractSummaryRequest";

type ReturnType = {
htmlParse: (html: string) => void;
sections: SectionType[];
isChatRequesting: boolean;
summarySections: SummarySectionType[];
};

export type SectionType = {
Expand All @@ -12,27 +18,57 @@ export type SectionType = {
sectionContent: string | undefined;
};

export type SummarySectionType = SectionType & {
sectionSummary: string;
};

export const useHtmlParse = (): ReturnType => {
const { items: summarySections, unshiftItem } =
useArray<SummarySectionType>();
const { doSummaryRequest, isChatRequesting } = useContractSummaryRequest("");
const [sections, setSections] = useState<SectionType[]>([]);

const htmlParse = useCallback((html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const pdfSections = Array.from(
doc.querySelectorAll("h2[id^='pdfSection-']")
);

const array = pdfSections.map((section) => ({
id: parseInt(section.id.replace("pdfSection-", "")),
sectionId: section.id,
sectionTitle: section.textContent || undefined,
sectionContent: section.nextElementSibling?.textContent || undefined,
}));
setSections(array);
}, []);
const handleAiRequest = useCallback(
(newSections: SectionType[]) => {
newSections.map((section) => {
doSummaryRequest({
message: `${section.sectionTitle}\n${section.sectionContent}`,
onSuccess: (data) => {
unshiftItem({
...section,
sectionSummary: data.chatMessage.content,
});
},
});
});
},
[doSummaryRequest, unshiftItem]
);

const htmlParse = useCallback(
(html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const pdfSections = Array.from(
doc.querySelectorAll("h2[id^='pdfSection-']")
);

const array = pdfSections.map((section) => ({
id: parseInt(section.id.replace("pdfSection-", "")),
sectionId: section.id,
sectionTitle: section.textContent || undefined,
sectionContent: section.nextElementSibling?.textContent || undefined,
}));
setSections(array);
handleAiRequest(array);
},
[handleAiRequest]
);

return {
htmlParse,
sections,
summarySections: summarySections.sort((a, b) => (a.id < b.id ? -1 : 1)),
isChatRequesting,
};
};
58 changes: 33 additions & 25 deletions src/features/home/hooks/usePdfLoad.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,60 @@
import { GlobalWorkerOptions, version, getDocument } from "pdfjs-dist";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";

import { useHtmlParse, type SectionType } from "./useHtmlParse";
import {
useHtmlParse,
type SectionType,
SummarySectionType,
} from "./useHtmlParse";

type ReturnType = {
loadPdfUrl: (pdfUrl: string) => void;
pdfHtml: string;
isChatRequesting: boolean;
sections: SectionType[];
summarySections: SummarySectionType[];
};

const h2Regex = /^第.{1,2}条[^、,。].+$/gm;
const pTextRegex = /<\/p>\s*<p>/gm;

export const usePdfLoad = (): ReturnType => {
const { sections, htmlParse } = useHtmlParse();
const { sections, htmlParse, isChatRequesting, summarySections } =
useHtmlParse();
GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.js`;

const [pdfHtml, setPdfHtml] = useState<string>("");

const loadPdfUrl = useCallback((pdfUrl: string) => {
const loadPdfUrl = async (pdfUrl: string) => {
const loadingTask = getDocument(pdfUrl);
loadingTask.promise.then((pdf) => {
const numPages = pdf.numPages;
[...Array(numPages)].map((_, index) => {
pdf.getPage(index + 1).then((page) => {
page.getTextContent().then((textContent) => {
const text = textContent.items.map(
(item) => "str" in item && generateStrHtml(item.str)
);
const prevText = index === 0 ? "" : "\n\n";
setPdfHtml(
(prev) =>
prev + `${prevText}${text.join("").replace(pTextRegex, "")}`
);
});
});
});
});
}, []);
const pdf = await loadingTask.promise;

useEffect(() => {
pdfHtml && htmlParse(pdfHtml);
}, [pdfHtml, htmlParse]);
const html = await Promise.all(
[...Array(pdf.numPages)].map(async (_, index) => {
const page = await pdf.getPage(index + 1);
const textContent = await page.getTextContent();
const text = textContent.items.map(
(item) => "str" in item && generateStrHtml(item.str)
);
const prevText = index === 0 ? "" : "\n\n";
return `${prevText}${text
.join("")
.replace(pTextRegex, "")
.replace(/<p>\n+/gm, "<p>")
.replace(/\n+<\/p>/gm, "</p>")}`;
})
);
const newHtml = html.join("");
setPdfHtml(newHtml);
htmlParse(newHtml);
};

return {
loadPdfUrl,
pdfHtml,
sections,
isChatRequesting,
summarySections,
};
};

Expand Down
Loading

0 comments on commit 5856fc7

Please sign in to comment.