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", "tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.0.46", "use-stick-to-bottom": "^1.0.46",
"uuid": "^11.0.5", "uuid": "^11.1.0",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
@@ -63,10 +63,10 @@
"@tailwindcss/postcss": "^4.0.13", "@tailwindcss/postcss": "^4.0.13",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"eslint": "^9.19.0", "eslint": "^9.19.0",
@@ -75,7 +75,6 @@
"eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0", "globals": "^15.14.0",
"next": "^15.2.3", "next": "^15.2.3",
"pdf-parse": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "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, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "../ui/tooltip"; } from "../ui/tooltip";
import type { Base64ContentBlock } from "@/lib/pdf"; import {
fileToImageBlock,
type MessageContentType = Message["content"]; fileToPDFBlock,
interface UploadedBlock { toOpenAIImageBlock,
id: string; toOpenAIPDFBlock,
name: string; } from "@/lib/multimodal-utils";
block: Base64ContentBlock; import type { Base64ContentBlock } from "@langchain/core/messages";
} import { convertToOpenAIImageBlock } from "@langchain/core/messages";
function StickyToBottomContent(props: { function StickyToBottomContent(props: {
content: ReactNode; content: ReactNode;
@@ -122,8 +122,8 @@ export function Thread() {
parseAsBoolean.withDefault(false), parseAsBoolean.withDefault(false),
); );
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [imageUrlList, setImageUrlList] = useState<UploadedBlock[]>([]); const [imageUrlList, setImageUrlList] = useState<Base64ContentBlock[]>([]);
const [pdfUrlList, setPdfUrlList] = useState<UploadedBlock[]>([]); const [pdfUrlList, setPdfUrlList] = useState<Base64ContentBlock[]>([]);
const [firstTokenReceived, setFirstTokenReceived] = useState(false); const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)"); const isLargeScreen = useMediaQuery("(min-width: 1024px)");
@@ -182,16 +182,23 @@ export function Thread() {
if (!input.trim() || isLoading) return; if (!input.trim() || isLoading) return;
setFirstTokenReceived(false); 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 = { const newHumanMessage: Message = {
id: uuidv4(), id: uuidv4(),
type: "human", type: "human",
content: [ content: [
{ type: "text", text: input }, { type: "text", text: input },
...imageUrlList.map((item) => item.block), ...imageUrlList.map(toOpenAIImageBlock),
...pdfUrlList.map((item) => item.block), ...pdfBlocks,
] as MessageContentType, ] as Message["content"],
}; };
console.log("Message content:", newHumanMessage.content);
const toolMessages = ensureToolCallsHaveResponses(stream.messages); const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit( stream.submit(
{ messages: [...toolMessages, newHumanMessage] }, { messages: [...toolMessages, newHumanMessage] },
@@ -210,37 +217,16 @@ export function Thread() {
setInput(""); setInput("");
setImageUrlList([]); setImageUrlList([]);
setPdfUrlList([]);
}; };
const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files) { if (files) {
const imageFiles: UploadedBlock[] = await Promise.all( const imageBlocks = await Promise.all(
Array.from(files).map((file) => { Array.from(files).map(fileToImageBlock)
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);
});
}),
); );
setImageUrlList([...imageUrlList, ...imageFiles]); setImageUrlList((prev) => [...prev, ...imageBlocks]);
} }
e.target.value = ""; e.target.value = "";
}; };
@@ -248,33 +234,10 @@ export function Thread() {
const handlePDFUpload = async (e: ChangeEvent<HTMLInputElement>) => { const handlePDFUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (files) { if (files) {
const pdfFiles: UploadedBlock[] = await Promise.all( const pdfBlocks = await Promise.all(
Array.from(files).map((file) => { Array.from(files).map(fileToPDFBlock)
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);
});
}),
); );
console.log(pdfFiles[0]); setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
setPdfUrlList([...pdfUrlList, ...pdfFiles]);
} }
e.target.value = ""; e.target.value = "";
}; };
@@ -312,87 +275,29 @@ export function Thread() {
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image/")); 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 ( if (invalidFiles.length > 0) {
files.some(
(file) =>
!file.type.startsWith("image/") || file.type !== "application/pdf",
)
) {
toast.error( 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) { if (imageFiles.length) {
const imageFilesData: UploadedBlock[] = await Promise.all( const imageBlocks: Base64ContentBlock[] = await Promise.all(
Array.from(imageFiles).map((file) => { imageFiles.map(fileToImageBlock)
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);
});
}),
); );
setImageUrlList([...imageUrlList, ...imageFilesData]); setImageUrlList((prev) => [...prev, ...imageBlocks]);
} }
/** if (pdfFiles.length) {
* If there are any PDF files in the dropped files, this block previews the file name of each uploaded PDF const pdfBlocks: Base64ContentBlock[] = await Promise.all(
* by rendering a list of file names above the input area, with a remove button for each. pdfFiles.map(fileToPDFBlock)
*/
if (files.some((file) => file.type === "application/pdf")) {
const pdfFiles = files.filter(
(file) => file.type === "application/pdf",
); );
const pdfFilesData: UploadedBlock[] = await Promise.all( setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
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]);
} }
}; };
@@ -613,13 +518,10 @@ export function Thread() {
> >
{imageUrlList.length > 0 && ( {imageUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0"> <div className="flex flex-wrap gap-2 p-3.5 pb-0">
{imageUrlList.map((imageItemWrapper) => { {imageUrlList.map((imageBlock, idx) => {
const imageUrlString = `data:${imageItemWrapper.block.mime_type};base64,${imageItemWrapper.block.data}`; const imageUrlString = `data:${imageBlock.mime_type};base64,${imageBlock.data}`;
return ( return (
<div <div className="relative" key={idx}>
className="relative"
key={imageItemWrapper.id}
>
<img <img
src={imageUrlString} src={imageUrlString}
alt="uploaded" alt="uploaded"
@@ -628,11 +530,7 @@ export function Thread() {
<CircleX <CircleX
className="absolute top-[2px] right-[2px] size-4 cursor-pointer rounded-full bg-gray-500 text-white" className="absolute top-[2px] right-[2px] size-4 cursor-pointer rounded-full bg-gray-500 text-white"
onClick={() => onClick={() =>
setImageUrlList( setImageUrlList(imageUrlList.filter((_, i) => i !== idx))
imageUrlList.filter(
(url) => url.id !== imageItemWrapper.id,
),
)
} }
/> />
</div> </div>
@@ -642,20 +540,18 @@ export function Thread() {
)} )}
{pdfUrlList.length > 0 && ( {pdfUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0"> <div className="flex flex-wrap gap-2 p-3.5 pb-0">
{pdfUrlList.map((pdf) => ( {pdfUrlList.map((pdfBlock, idx) => (
<div <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" 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"> <span className="max-w-xs truncate text-sm">
{pdf.name} {String(pdfBlock.metadata?.filename ?? pdfBlock.metadata?.name ?? "")}
</span> </span>
<CircleX <CircleX
className="size-4 cursor-pointer text-teal-600 hover:text-teal-500" className="size-4 cursor-pointer text-teal-600 hover:text-teal-500"
onClick={() => onClick={() =>
setPdfUrlList( setPdfUrlList(pdfUrlList.filter((_, i) => i !== idx))
pdfUrlList.filter((p) => p.id !== pdf.id),
)
} }
/> />
</div> </div>

View File

@@ -1,11 +1,30 @@
import type { Message } from "@langchain/langgraph-sdk"; 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 { export function getContentString(content: Message["content"]): string {
if (typeof content === "string") return content; if (typeof content === "string") return content;
const texts = content const texts = content
.filter((c): c is { type: "text"; text: string } => c.type === "text") .filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.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[] { 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,/, "");
};