feat:file attachment ai chat
This commit is contained in:
parent
e240d79d7c
commit
0ecf0de954
|
|
@ -15,6 +15,7 @@ import {
|
|||
Send,
|
||||
Mic,
|
||||
Calendar,
|
||||
DownloadIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import {
|
||||
|
|
@ -30,6 +31,16 @@ import { toast } from "sonner";
|
|||
import Navbar from "@/components/navbar";
|
||||
import Link from "next/link";
|
||||
import { getCookiesDecrypt, textEllipsis } from "@/utils/globals";
|
||||
import { AIChatFilesData, getAiChatFiles } from "@/service/ai-chat";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FileViewer } from "@/components/modal/ai-chat-viewer";
|
||||
import { FileIcon, PdfIcon, VideoIcon } from "@/components/icons";
|
||||
|
||||
const token = Cookies.get("access_token");
|
||||
|
||||
|
|
@ -49,6 +60,7 @@ interface ExternalChatRun {
|
|||
response_audio: any[];
|
||||
};
|
||||
}
|
||||
type ChatFilesMap = Record<string, any>;
|
||||
|
||||
// interface ExternalChatResponse {
|
||||
// sessionId: string;
|
||||
|
|
@ -114,6 +126,8 @@ export default function HistoryDetailPage() {
|
|||
const [fileLength, setFileLength] = useState(0);
|
||||
const userId = getCookiesDecrypt("uie");
|
||||
|
||||
const [aiChatFiles, setAiChatFilse] = useState<ChatFilesMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
fetchSessionDetails();
|
||||
|
|
@ -351,6 +365,10 @@ export default function HistoryDetailPage() {
|
|||
|
||||
const data = await response.json();
|
||||
setExternalChatData(data.data);
|
||||
if (data?.data) {
|
||||
const datas = await getChatFiles(data?.data);
|
||||
setAiChatFilse(datas);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching external chat data:", error);
|
||||
setError("Gagal memuat data chat dari server eksternal");
|
||||
|
|
@ -359,6 +377,21 @@ export default function HistoryDetailPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const getChatFiles = async (data: HistoryData[]) => {
|
||||
const result: ChatFilesMap = {};
|
||||
|
||||
for (const element of data) {
|
||||
if (element.messageType === "user") {
|
||||
const files = await getAiChatFiles({ messageId: element.id });
|
||||
if (files?.data) {
|
||||
result[element.id] = files?.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: string | number) => {
|
||||
const date = new Date(
|
||||
typeof timestamp === "string" ? timestamp : timestamp * 1000
|
||||
|
|
@ -467,7 +500,7 @@ export default function HistoryDetailPage() {
|
|||
|
||||
{/* Chat Messages */}
|
||||
<div className="flex justify-center overflow-y-auto p-4 scrollbar-enhanced">
|
||||
<div className="w-[90vw] lg:w-[1000px] lg:ml-3">
|
||||
<div className="w-[90vw] lg:w-250 lg:ml-3">
|
||||
{loadingExternal ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -507,8 +540,59 @@ export default function HistoryDetailPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : run.content === "Voice Note" ? (
|
||||
<div>
|
||||
{" "}
|
||||
<video
|
||||
controls
|
||||
className="max-w-full max-h-full object-contain mx-auto rounded"
|
||||
>
|
||||
<source src={aiChatFiles[run.id]} />
|
||||
Browser tidak mendukung video.
|
||||
</video>
|
||||
</div>
|
||||
) : (
|
||||
<p className="whitespace-pre-line">{run.content}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{aiChatFiles[run.id] &&
|
||||
aiChatFiles[run.id].map(
|
||||
(file: AIChatFilesData, index: number) => (
|
||||
<div key={file.id}>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
{renderIconAiChatFile(
|
||||
file.fileUrl,
|
||||
file.fileAlt
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className={`max-w-[90vw] w-[90vw] p-0 overflow-hidden ${
|
||||
file.fileUrl
|
||||
.split(".")
|
||||
.pop()
|
||||
?.toLowerCase() === "pdf"
|
||||
? "h-[70vh]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="hidden">
|
||||
<DialogTitle></DialogTitle>
|
||||
</div>
|
||||
<div className="flex-1 items-start overflow-auto p-4">
|
||||
<FileViewer
|
||||
url={file.fileUrl}
|
||||
filename={file.fileAlt}
|
||||
/>
|
||||
</div>{" "}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="whitespace-pre-line">{run.content}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm mt-1 opacity-70">
|
||||
{formatDate(run.createdAt)}
|
||||
|
|
@ -556,3 +640,32 @@ export default function HistoryDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderIconAiChatFile = (url: string, filename: string) => {
|
||||
const cleanUrl = url.split("?")[0];
|
||||
const ext = cleanUrl.split(".").pop()?.toLowerCase();
|
||||
|
||||
if (!ext) {
|
||||
return <FileIcon />;
|
||||
}
|
||||
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={filename ?? "file"}
|
||||
className="w-12 h-12 rounded border object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === "pdf") {
|
||||
return <PdfIcon size={48} />;
|
||||
}
|
||||
|
||||
if (["mp4", "webm", "ogg"].includes(ext)) {
|
||||
return <VideoIcon size={48} />;
|
||||
}
|
||||
|
||||
return <DownloadIcon />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -400,6 +400,14 @@ export default function Chat() {
|
|||
session_id: currentSessionId,
|
||||
title: audioBlob ? "Voice Chat" : currentInput.substring(0, 100),
|
||||
messages: updatedMessages,
|
||||
files: {
|
||||
audioBlob: audioBlob
|
||||
? new File([audioBlob], "audio.webm", {
|
||||
type: "audio/webm",
|
||||
})
|
||||
: null,
|
||||
attachment: selectedFile,
|
||||
},
|
||||
});
|
||||
|
||||
historyId = res?.data?.id;
|
||||
|
|
@ -690,7 +698,7 @@ export default function Chat() {
|
|||
}
|
||||
{selectedExpert.length > 1 && (
|
||||
<Select value={taggedAgent} onValueChange={setTaggedAgent}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectTrigger className="w-45">
|
||||
<AtSign className="w-5 h-5 text-gray-500" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
|
||||
type FileViewerProps = {
|
||||
url: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
const getFileType = (url: string) => {
|
||||
const cleanUrl = url.split("?")[0];
|
||||
const ext = cleanUrl.split(".").pop()?.toLowerCase();
|
||||
|
||||
return ext;
|
||||
};
|
||||
|
||||
export const FileViewer: React.FC<FileViewerProps> = ({ url, filename }) => {
|
||||
const ext = getFileType(url);
|
||||
|
||||
if (!ext) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
Buka file
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={filename ?? "file"}
|
||||
className="max-w-full max-h-full object-contain mx-auto rounded border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ext === "pdf") {
|
||||
return (
|
||||
<iframe
|
||||
src={url}
|
||||
title={filename ?? "PDF Viewer"}
|
||||
className="w-full h-full object-contain mx-auto border rounded"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (["mp4", "webm", "ogg"].includes(ext)) {
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
className="max-w-full max-h-full object-contain mx-auto rounded"
|
||||
>
|
||||
<source src={url} />
|
||||
Browser tidak mendukung video.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
if (["mp3", "wav", "ogg"].includes(ext)) {
|
||||
return (
|
||||
<audio controls className="max-w-full max-h-full object-contain mx-auto">
|
||||
<source src={url} />
|
||||
Browser tidak mendukung audio.
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
>
|
||||
Download {filename ?? ext.toUpperCase()}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Utility functions for chat history management
|
||||
import { getHistorySession } from "@/service/ai-chat";
|
||||
import { getHistorySession, uploadAiChatFiles } from "@/service/ai-chat";
|
||||
import Cookies from "js-cookie";
|
||||
const token = Cookies.get("access_token");
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ export async function saveChatHistory(data: {
|
|||
session_id: string;
|
||||
title?: string;
|
||||
messages?: ChatMessage[];
|
||||
files: { audioBlob: any; attachment: any };
|
||||
}) {
|
||||
const response = await fetch("https://narasiahli.com/api/ai-chat/sessions", {
|
||||
method: "POST",
|
||||
|
|
@ -63,7 +64,6 @@ export async function saveChatHistory(data: {
|
|||
title: data.title,
|
||||
}),
|
||||
});
|
||||
console.log("ressp", response);
|
||||
|
||||
if (data.messages) {
|
||||
for (const element of data.messages) {
|
||||
|
|
@ -82,6 +82,30 @@ export async function saveChatHistory(data: {
|
|||
}),
|
||||
}
|
||||
);
|
||||
const result = await response2.json();
|
||||
const messageId = result?.data?.id;
|
||||
const files = data.files;
|
||||
if (element.type === "user") {
|
||||
if (element.content === "Voice Note" && files.audioBlob) {
|
||||
const formDataAudio = new FormData();
|
||||
formDataAudio.append("files", files.audioBlob);
|
||||
const saveVoiceNote = await uploadAiChatFiles(
|
||||
messageId,
|
||||
formDataAudio
|
||||
);
|
||||
}
|
||||
|
||||
if (files.attachment && files.attachment.length > 0) {
|
||||
const formDataFiles = new FormData();
|
||||
for (const element of files.attachment) {
|
||||
formDataFiles.append("files", element);
|
||||
}
|
||||
const saveAttachment = await uploadAiChatFiles(
|
||||
messageId,
|
||||
formDataFiles
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,51 @@ import {
|
|||
httpPutInterceptor,
|
||||
} from "./http-config/http-interceptor-services";
|
||||
|
||||
import { httpGet } from "./http-config/http-base-services";
|
||||
|
||||
export interface AIChatFilesData {
|
||||
id: number;
|
||||
messageId: number;
|
||||
fileUrl: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
fileAlt: string;
|
||||
size: string;
|
||||
isActive: true;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AIChatFilesResponse {
|
||||
success: boolean;
|
||||
code: number;
|
||||
messages: string[];
|
||||
data: AIChatFilesData[];
|
||||
}
|
||||
|
||||
export async function getHistorySession(data: { limit: number }) {
|
||||
const response = await httpGetInterceptor(
|
||||
`/ai-chat/sessions?limit=${data.limit}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAiChatFiles(data: { messageId: number }) {
|
||||
const response = await httpGet(`/ai-chat-files/by-message/${data.messageId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadAiChatFiles(id: number, file: any) {
|
||||
// const formData = new FormData();
|
||||
// formData.append("file", file);
|
||||
|
||||
const response = await httpPostInterceptor(`/ai-chat-files/${id}`, file, {
|
||||
"Content-Type": "multipart/form-data",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getEbookDetail(id: number): Promise<AIChatFilesResponse> {
|
||||
const response = await httpGetInterceptor(`/ebooks/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue