major refactor
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-stick-to-bottom": "^1.0.46",
|
||||
"uuid": "^11.0.5",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -63,10 +63,10 @@
|
||||
"@tailwindcss/postcss": "^4.0.13",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9.19.0",
|
||||
@@ -75,7 +75,6 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"next": "^15.2.3",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
||||
748
pnpm-lock.yaml
generated
748
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -37,14 +37,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import type { Base64ContentBlock } from "@/lib/pdf";
|
||||
|
||||
type MessageContentType = Message["content"];
|
||||
interface UploadedBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
block: Base64ContentBlock;
|
||||
}
|
||||
import {
|
||||
fileToImageBlock,
|
||||
fileToPDFBlock,
|
||||
toOpenAIImageBlock,
|
||||
toOpenAIPDFBlock,
|
||||
} from "@/lib/multimodal-utils";
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { convertToOpenAIImageBlock } from "@langchain/core/messages";
|
||||
|
||||
function StickyToBottomContent(props: {
|
||||
content: ReactNode;
|
||||
@@ -122,8 +122,8 @@ export function Thread() {
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [imageUrlList, setImageUrlList] = useState<UploadedBlock[]>([]);
|
||||
const [pdfUrlList, setPdfUrlList] = useState<UploadedBlock[]>([]);
|
||||
const [imageUrlList, setImageUrlList] = useState<Base64ContentBlock[]>([]);
|
||||
const [pdfUrlList, setPdfUrlList] = useState<Base64ContentBlock[]>([]);
|
||||
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
|
||||
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
@@ -181,17 +181,24 @@ export function Thread() {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
setFirstTokenReceived(false);
|
||||
|
||||
// TODO: check configurable object for modelname camelcase or snakecase else do openai format
|
||||
const isOpenAI = true
|
||||
|
||||
const pdfBlocks = pdfUrlList.map(toOpenAIPDFBlock);
|
||||
|
||||
const newHumanMessage: Message = {
|
||||
id: uuidv4(),
|
||||
type: "human",
|
||||
content: [
|
||||
{ type: "text", text: input },
|
||||
...imageUrlList.map((item) => item.block),
|
||||
...pdfUrlList.map((item) => item.block),
|
||||
] as MessageContentType,
|
||||
...imageUrlList.map(toOpenAIImageBlock),
|
||||
...pdfBlocks,
|
||||
] as Message["content"],
|
||||
};
|
||||
|
||||
console.log("Message content:", newHumanMessage.content);
|
||||
|
||||
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
|
||||
stream.submit(
|
||||
{ messages: [...toolMessages, newHumanMessage] },
|
||||
@@ -210,37 +217,16 @@ export function Thread() {
|
||||
|
||||
setInput("");
|
||||
setImageUrlList([]);
|
||||
setPdfUrlList([]);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
const imageFiles: UploadedBlock[] = await Promise.all(
|
||||
Array.from(files).map((file) => {
|
||||
return new Promise<UploadedBlock>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
const match = result.match(/^data:(.*);base64/);
|
||||
const mimeType = match && match[1] ? match[1] : file.type;
|
||||
resolve({
|
||||
id: uuidv4(),
|
||||
name: file.name,
|
||||
block: {
|
||||
type: "image",
|
||||
source_type: "base64",
|
||||
data: base64,
|
||||
mime_type: mimeType,
|
||||
metadata: { name: file.name },
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}),
|
||||
const imageBlocks = await Promise.all(
|
||||
Array.from(files).map(fileToImageBlock)
|
||||
);
|
||||
setImageUrlList([...imageUrlList, ...imageFiles]);
|
||||
setImageUrlList((prev) => [...prev, ...imageBlocks]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
@@ -248,33 +234,10 @@ export function Thread() {
|
||||
const handlePDFUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
const pdfFiles: UploadedBlock[] = await Promise.all(
|
||||
Array.from(files).map((file) => {
|
||||
return new Promise<UploadedBlock>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
const match = result.match(/^data:(.*);base64/);
|
||||
const mimeType = match && match[1] ? match[1] : "application/pdf";
|
||||
resolve({
|
||||
id: uuidv4(),
|
||||
name: file.name,
|
||||
block: {
|
||||
type: "file",
|
||||
source_type: "base64",
|
||||
data: base64,
|
||||
mime_type: mimeType,
|
||||
metadata: { name: file.name },
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}),
|
||||
const pdfBlocks = await Promise.all(
|
||||
Array.from(files).map(fileToPDFBlock)
|
||||
);
|
||||
console.log(pdfFiles[0]);
|
||||
setPdfUrlList([...pdfUrlList, ...pdfFiles]);
|
||||
setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
@@ -312,87 +275,29 @@ export function Thread() {
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image/"));
|
||||
const pdfFiles = files.filter((file) => file.type === "application/pdf");
|
||||
const invalidFiles = files.filter(
|
||||
(file) => !file.type.startsWith("image/") && file.type !== "application/pdf"
|
||||
);
|
||||
|
||||
if (
|
||||
files.some(
|
||||
(file) =>
|
||||
!file.type.startsWith("image/") || file.type !== "application/pdf",
|
||||
)
|
||||
) {
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error(
|
||||
"You have uploaded invalid file type. Please upload an image or a PDF.",
|
||||
"You have uploaded invalid file type. Please upload an image or a PDF."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are any image files in the dropped files, this block reads each image file as a data URL,
|
||||
* wraps it in a MessageContentImageWrapper object, and updates the imageUrlList state with the new images.
|
||||
* This enables preview and later sending of uploaded images in the chat UI.
|
||||
*/
|
||||
if (imageFiles.length) {
|
||||
const imageFilesData: UploadedBlock[] = await Promise.all(
|
||||
Array.from(imageFiles).map((file) => {
|
||||
return new Promise<UploadedBlock>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
const match = result.match(/^data:(.*);base64/);
|
||||
const mimeType = match && match[1] ? match[1] : file.type;
|
||||
resolve({
|
||||
id: uuidv4(),
|
||||
name: file.name,
|
||||
block: {
|
||||
type: "image",
|
||||
source_type: "base64",
|
||||
data: base64,
|
||||
mime_type: mimeType,
|
||||
metadata: { name: file.name },
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}),
|
||||
const imageBlocks: Base64ContentBlock[] = await Promise.all(
|
||||
imageFiles.map(fileToImageBlock)
|
||||
);
|
||||
setImageUrlList([...imageUrlList, ...imageFilesData]);
|
||||
setImageUrlList((prev) => [...prev, ...imageBlocks]);
|
||||
}
|
||||
|
||||
/**
|
||||
* If there are any PDF files in the dropped files, this block previews the file name of each uploaded PDF
|
||||
* by rendering a list of file names above the input area, with a remove button for each.
|
||||
*/
|
||||
if (files.some((file) => file.type === "application/pdf")) {
|
||||
const pdfFiles = files.filter(
|
||||
(file) => file.type === "application/pdf",
|
||||
if (pdfFiles.length) {
|
||||
const pdfBlocks: Base64ContentBlock[] = await Promise.all(
|
||||
pdfFiles.map(fileToPDFBlock)
|
||||
);
|
||||
const pdfFilesData: UploadedBlock[] = await Promise.all(
|
||||
pdfFiles.map((file) => {
|
||||
return new Promise<UploadedBlock>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
const match = result.match(/^data:(.*);base64/);
|
||||
const mimeType =
|
||||
match && match[1] ? match[1] : "application/pdf";
|
||||
resolve({
|
||||
id: uuidv4(),
|
||||
name: file.name,
|
||||
block: {
|
||||
type: "file",
|
||||
source_type: "base64",
|
||||
data: base64,
|
||||
mime_type: mimeType,
|
||||
metadata: { name: file.name },
|
||||
},
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}),
|
||||
);
|
||||
setPdfUrlList([...pdfUrlList, ...pdfFilesData]);
|
||||
setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -613,13 +518,10 @@ export function Thread() {
|
||||
>
|
||||
{imageUrlList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
|
||||
{imageUrlList.map((imageItemWrapper) => {
|
||||
const imageUrlString = `data:${imageItemWrapper.block.mime_type};base64,${imageItemWrapper.block.data}`;
|
||||
{imageUrlList.map((imageBlock, idx) => {
|
||||
const imageUrlString = `data:${imageBlock.mime_type};base64,${imageBlock.data}`;
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
key={imageItemWrapper.id}
|
||||
>
|
||||
<div className="relative" key={idx}>
|
||||
<img
|
||||
src={imageUrlString}
|
||||
alt="uploaded"
|
||||
@@ -628,11 +530,7 @@ export function Thread() {
|
||||
<CircleX
|
||||
className="absolute top-[2px] right-[2px] size-4 cursor-pointer rounded-full bg-gray-500 text-white"
|
||||
onClick={() =>
|
||||
setImageUrlList(
|
||||
imageUrlList.filter(
|
||||
(url) => url.id !== imageItemWrapper.id,
|
||||
),
|
||||
)
|
||||
setImageUrlList(imageUrlList.filter((_, i) => i !== idx))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -642,20 +540,18 @@ export function Thread() {
|
||||
)}
|
||||
{pdfUrlList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
|
||||
{pdfUrlList.map((pdf) => (
|
||||
{pdfUrlList.map((pdfBlock, idx) => (
|
||||
<div
|
||||
className="relative flex items-center gap-2 rounded rounded-md border-1 border-teal-700 bg-gray-100 bg-teal-900 px-2 py-1 py-2 text-white"
|
||||
key={pdf.id}
|
||||
key={idx}
|
||||
>
|
||||
<span className="max-w-xs truncate text-sm">
|
||||
{pdf.name}
|
||||
{String(pdfBlock.metadata?.filename ?? pdfBlock.metadata?.name ?? "")}
|
||||
</span>
|
||||
<CircleX
|
||||
className="size-4 cursor-pointer text-teal-600 hover:text-teal-500"
|
||||
onClick={() =>
|
||||
setPdfUrlList(
|
||||
pdfUrlList.filter((p) => p.id !== pdf.id),
|
||||
)
|
||||
setPdfUrlList(pdfUrlList.filter((_, i) => i !== idx))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
/**
|
||||
* Extracts a string summary from a message's content, supporting multimodal (text, image, file, etc.).
|
||||
* - If text is present, returns the joined text.
|
||||
* - If not, returns a label for the first non-text modality (e.g., 'Image', 'Other').
|
||||
* - If unknown, returns 'Multimodal message'.
|
||||
*/
|
||||
export function getContentString(content: Message["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
const texts = content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text);
|
||||
return texts.join(" ");
|
||||
if (texts.length > 0) return texts.join(" ");
|
||||
// Handle multimodal: fallback to first non-text type
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
const first = content[0];
|
||||
if (typeof first === "object" && first !== null && "type" in first) {
|
||||
switch (first.type) {
|
||||
case "image_url":
|
||||
return "Image";
|
||||
default:
|
||||
return "Other";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Multimodal message";
|
||||
}
|
||||
|
||||
export function getContentImageUrls(content: Message["content"]): string[] {
|
||||
|
||||
31
src/index.ts
Normal file
31
src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Annotation,
|
||||
MessagesAnnotation,
|
||||
START,
|
||||
StateGraph,
|
||||
} from "@langchain/langgraph";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
|
||||
const ChatAgentAnnotation = Annotation.Root({
|
||||
messages: MessagesAnnotation.spec["messages"],
|
||||
});
|
||||
|
||||
const graph = new StateGraph(ChatAgentAnnotation)
|
||||
.addNode("chat", async (state) => {
|
||||
const model = new ChatOpenAI({
|
||||
model: "gpt-4o-mini",
|
||||
});
|
||||
|
||||
const response = await model.invoke([
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
...state.messages,
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: response,
|
||||
};
|
||||
})
|
||||
.addEdge(START, "chat");
|
||||
|
||||
export const agent = graph.compile();
|
||||
agent.name = "Chat Agent";
|
||||
67
src/lib/multimodal-utils.ts
Normal file
67
src/lib/multimodal-utils.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Base64ContentBlock } from "@langchain/core/messages";
|
||||
import { convertToOpenAIImageBlock } from "@langchain/core/messages";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Returns a Promise of a typed multimodal block for images
|
||||
export async function fileToImageBlock(file: File): Promise<Base64ContentBlock> {
|
||||
const data = await fileToBase64(file);
|
||||
return {
|
||||
type: "image",
|
||||
source_type: "base64",
|
||||
mime_type: file.type,
|
||||
data,
|
||||
metadata: { name: file.name },
|
||||
};
|
||||
}
|
||||
|
||||
// Returns a Promise of a typed multimodal block for PDFs
|
||||
export async function fileToPDFBlock(file: File): Promise<Base64ContentBlock>{
|
||||
const data = await fileToBase64(file);
|
||||
return {
|
||||
type: "file",
|
||||
source_type: "base64",
|
||||
mime_type: "application/pdf",
|
||||
data,
|
||||
metadata: { name: file.name, filename: file.name },
|
||||
};
|
||||
}
|
||||
|
||||
// in lib/multimodal-utils.ts
|
||||
export function toOpenAIPDFBlock(block: Base64ContentBlock) {
|
||||
return {
|
||||
type: "file",
|
||||
file: {
|
||||
source_type: "base64",
|
||||
data: block.data,
|
||||
mime_type: block.mime_type ?? "application/pdf",
|
||||
filename: block.metadata?.name ?? block.metadata?.filename ?? "file.pdf",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to convert File to base64 string
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove the data:...;base64, prefix
|
||||
resolve(result.split(",")[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Utility to convert base64 image blocks to OpenAI image_url format
|
||||
export function toOpenAIImageBlock(block: Base64ContentBlock | any) {
|
||||
if (block.type === "image" && block.source_type === "base64") {
|
||||
return convertToOpenAIImageBlock(block);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
const cleanBase64 = (base64String: string): string => {
|
||||
return base64String.replace(/^data:.*?;base64,/, "");
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
"use server";
|
||||
import { MessageContentText } from "@langchain/core/messages";
|
||||
import { WebPDFLoader } from "@langchain/community/document_loaders/web/pdf";
|
||||
// import { Base64ContentBlock } from "@langchain/core/messages";
|
||||
|
||||
// switch local import with above import
|
||||
export interface Base64ContentBlock {
|
||||
data: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
mime_type?: string;
|
||||
source_type: "base64";
|
||||
type: "image" | "audio" | "file";
|
||||
}
|
||||
|
||||
export const extractPdfText = async (
|
||||
file: File,
|
||||
): Promise<MessageContentText> => {
|
||||
const loader = new WebPDFLoader(file, { splitPages: false });
|
||||
const docs = await loader.load();
|
||||
return {
|
||||
type: "text",
|
||||
text: docs[0].pageContent,
|
||||
};
|
||||
};
|
||||
|
||||
const cleanBase64 = (base64String: string): string => {
|
||||
return base64String.replace(/^data:.*?;base64,/, "");
|
||||
};
|
||||
Reference in New Issue
Block a user