feat:file attachment ai chat

This commit is contained in:
Rama Priyanto 2026-01-08 14:02:25 +07:00
parent e240d79d7c
commit 0ecf0de954
5 changed files with 269 additions and 5 deletions

View File

@ -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 />;
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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
);
}
}
}
}

View File

@ -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;
}