major refactor

This commit is contained in:
starmorph
2025-05-16 20:17:17 -07:00
parent 9ac2228039
commit 1cac35fe10
7 changed files with 311 additions and 789 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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)");
@@ -182,16 +182,23 @@ export function Thread() {
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>

View File

@@ -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
View 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";

View 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,/, "");
};

View File

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