Skip to content

Commit 8d7f67d

Browse files
authored
Merge pull request #1481 from 0xPBIT/images-in-chat-client
feat: add image features to react chat client
2 parents 8eefb03 + b2742af commit 8d7f67d

File tree

9 files changed

+18118
-22771
lines changed

9 files changed

+18118
-22771
lines changed

client/src/Chat.tsx

+68-14
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,75 @@
1-
import { useState } from "react";
1+
import { useRef, useState } from "react";
22
import { useParams } from "react-router-dom";
33
import { useMutation } from "@tanstack/react-query";
44
import { Button } from "@/components/ui/button";
5+
import { ImageIcon } from "lucide-react";
56
import { Input } from "@/components/ui/input";
67
import "./App.css";
8+
import path from "path";
79

810
type TextResponse = {
911
text: string;
1012
user: string;
13+
attachments?: { url: string; contentType: string; title: string }[];
1114
};
1215

1316
export default function Chat() {
1417
const { agentId } = useParams();
1518
const [input, setInput] = useState("");
1619
const [messages, setMessages] = useState<TextResponse[]>([]);
20+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
21+
const fileInputRef = useRef<HTMLInputElement>(null);
1722

1823
const mutation = useMutation({
1924
mutationFn: async (text: string) => {
25+
const formData = new FormData();
26+
formData.append("text", text);
27+
formData.append("userId", "user");
28+
formData.append("roomId", `default-room-${agentId}`);
29+
30+
if (selectedFile) {
31+
formData.append("file", selectedFile);
32+
}
33+
2034
const res = await fetch(`/api/${agentId}/message`, {
2135
method: "POST",
22-
headers: {
23-
"Content-Type": "application/json",
24-
},
25-
body: JSON.stringify({
26-
text,
27-
userId: "user",
28-
roomId: `default-room-${agentId}`,
29-
}),
36+
body: formData,
3037
});
3138
return res.json() as Promise<TextResponse[]>;
3239
},
3340
onSuccess: (data) => {
3441
setMessages((prev) => [...prev, ...data]);
42+
setSelectedFile(null);
3543
},
3644
});
3745

3846
const handleSubmit = async (e: React.FormEvent) => {
3947
e.preventDefault();
40-
if (!input.trim()) return;
48+
if (!input.trim() && !selectedFile) return;
4149

4250
// Add user message immediately to state
4351
const userMessage: TextResponse = {
4452
text: input,
4553
user: "user",
54+
attachments: selectedFile ? [{ url: URL.createObjectURL(selectedFile), contentType: selectedFile.type, title: selectedFile.name }] : undefined,
4655
};
4756
setMessages((prev) => [...prev, userMessage]);
4857

4958
mutation.mutate(input);
5059
setInput("");
5160
};
5261

62+
const handleFileSelect = () => {
63+
fileInputRef.current?.click();
64+
};
65+
66+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
67+
const file = e.target.files?.[0];
68+
if (file && file.type.startsWith('image/')) {
69+
setSelectedFile(file);
70+
}
71+
};
72+
5373
return (
5474
<div className="flex flex-col h-screen max-h-screen w-full">
5575
<div className="flex-1 min-h-0 overflow-y-auto p-4">
@@ -71,10 +91,23 @@ export default function Chat() {
7191
: "bg-muted"
7292
}`}
7393
>
74-
<pre className="whitespace-pre-wrap break-words font-sans m-0">
75-
{message.text}
76-
</pre>
77-
</div>
94+
{message.text}
95+
{message.attachments?.map((attachment, i) => (
96+
attachment.contentType.startsWith('image/') && (
97+
<img
98+
key={i}
99+
src={message.user === "user"
100+
? attachment.url
101+
: attachment.url.startsWith('http')
102+
? attachment.url
103+
: `http://localhost:3000/media/generated/${attachment.url.split('/').pop()}`
104+
}
105+
alt={attachment.title || "Attached image"}
106+
className="mt-2 max-w-full rounded-lg"
107+
/>
108+
)
109+
))}
110+
</div>
78111
</div>
79112
))
80113
) : (
@@ -88,17 +121,38 @@ export default function Chat() {
88121
<div className="border-t p-4 bg-background">
89122
<div className="max-w-3xl mx-auto">
90123
<form onSubmit={handleSubmit} className="flex gap-2">
124+
<input
125+
type="file"
126+
ref={fileInputRef}
127+
onChange={handleFileChange}
128+
accept="image/*"
129+
className="hidden"
130+
/>
91131
<Input
92132
value={input}
93133
onChange={(e) => setInput(e.target.value)}
94134
placeholder="Type a message..."
95135
className="flex-1"
96136
disabled={mutation.isPending}
97137
/>
138+
<Button
139+
type="button"
140+
variant="outline"
141+
size="icon"
142+
onClick={handleFileSelect}
143+
disabled={mutation.isPending}
144+
>
145+
<ImageIcon className="h-4 w-4" />
146+
</Button>
98147
<Button type="submit" disabled={mutation.isPending}>
99148
{mutation.isPending ? "..." : "Send"}
100149
</Button>
101150
</form>
151+
{selectedFile && (
152+
<div className="mt-2 text-sm text-muted-foreground">
153+
Selected file: {selectedFile.name}
154+
</div>
155+
)}
102156
</div>
103157
</div>
104158
</div>

packages/client-direct/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"multer": "1.4.5-lts.1"
1818
},
1919
"devDependencies": {
20-
"tsup": "8.3.5"
20+
"tsup": "8.3.5",
21+
"@types/multer": "^1.4.12"
2122
},
2223
"scripts": {
2324
"build": "tsup --format esm --dts",

packages/client-direct/src/index.ts

+53-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import bodyParser from "body-parser";
22
import cors from "cors";
33
import express, { Request as ExpressRequest } from "express";
4-
import multer, { File } from "multer";
4+
import multer from "multer";
55
import {
66
elizaLogger,
77
generateCaption,
88
generateImage,
9-
getEmbeddingZeroVector,
9+
Media,
10+
getEmbeddingZeroVector
1011
} from "@elizaos/core";
1112
import { composeContext } from "@elizaos/core";
1213
import { generateMessageResponse } from "@elizaos/core";
@@ -24,7 +25,23 @@ import { settings } from "@elizaos/core";
2425
import { createApiRouter } from "./api.ts";
2526
import * as fs from "fs";
2627
import * as path from "path";
27-
const upload = multer({ storage: multer.memoryStorage() });
28+
29+
const storage = multer.diskStorage({
30+
destination: (req, file, cb) => {
31+
const uploadDir = path.join(process.cwd(), "data", "uploads");
32+
// Create the directory if it doesn't exist
33+
if (!fs.existsSync(uploadDir)) {
34+
fs.mkdirSync(uploadDir, { recursive: true });
35+
}
36+
cb(null, uploadDir);
37+
},
38+
filename: (req, file, cb) => {
39+
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
40+
cb(null, `${uniqueSuffix}-${file.originalname}`);
41+
},
42+
});
43+
44+
const upload = multer({ storage });
2845

2946
export const messageHandlerTemplate =
3047
// {{goals}}
@@ -71,12 +88,22 @@ export class DirectClient {
7188
this.app.use(bodyParser.json());
7289
this.app.use(bodyParser.urlencoded({ extended: true }));
7390

91+
// Serve both uploads and generated images
92+
this.app.use(
93+
"/media/uploads",
94+
express.static(path.join(process.cwd(), "/data/uploads"))
95+
);
96+
this.app.use(
97+
"/media/generated",
98+
express.static(path.join(process.cwd(), "/generatedImages"))
99+
);
100+
74101
const apiRouter = createApiRouter(this.agents, this);
75102
this.app.use(apiRouter);
76103

77104
// Define an interface that extends the Express Request interface
78105
interface CustomRequest extends ExpressRequest {
79-
file: File;
106+
file?: Express.Multer.File;
80107
}
81108

82109
// Update the route handler to use CustomRequest instead of express.Request
@@ -133,6 +160,7 @@ export class DirectClient {
133160

134161
this.app.post(
135162
"/:agentId/message",
163+
upload.single("file"),
136164
async (req: express.Request, res: express.Response) => {
137165
const agentId = req.params.agentId;
138166
const roomId = stringToUuid(
@@ -167,9 +195,29 @@ export class DirectClient {
167195
const text = req.body.text;
168196
const messageId = stringToUuid(Date.now().toString());
169197

198+
const attachments: Media[] = [];
199+
if (req.file) {
200+
const filePath = path.join(
201+
process.cwd(),
202+
"agent",
203+
"data",
204+
"uploads",
205+
req.file.filename
206+
);
207+
attachments.push({
208+
id: Date.now().toString(),
209+
url: filePath,
210+
title: req.file.originalname,
211+
source: "direct",
212+
description: `Uploaded file: ${req.file.originalname}`,
213+
text: "",
214+
contentType: req.file.mimetype,
215+
});
216+
}
217+
170218
const content: Content = {
171219
text,
172-
attachments: [],
220+
attachments,
173221
source: "direct",
174222
inReplyTo: undefined,
175223
};

0 commit comments

Comments
 (0)