Skip to content

Commit 83102eb

Browse files
committed
Working
1 parent 4c7c2eb commit 83102eb

File tree

13 files changed

+197
-18
lines changed

13 files changed

+197
-18
lines changed

apps/web/client/public/onlook-preload-script.js

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/web/client/src/app/_components/hero/create.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { observer } from 'mobx-react-lite';
1919
import { AnimatePresence } from 'motion/react';
2020
import { useRouter } from 'next/navigation';
2121
import { useEffect, useRef, useState } from 'react';
22+
import { v4 as uuidv4 } from 'uuid';
2223

2324
const SAVED_INPUT_KEY = 'create-input';
2425
interface CreateInputContext {
@@ -200,13 +201,14 @@ export const Create = observer(({
200201
content: base64,
201202
displayName: file.name,
202203
mimeType: file.type,
204+
id: uuidv4(),
203205
};
204206
} catch (error) {
205207
console.error('Error reading file:', error);
206208
return null;
207209
}
208210
};
209-
211+
210212
const handleDragStateChange = (isDragging: boolean, e: React.DragEvent) => {
211213
const hasImage =
212214
e.dataTransfer.types.length > 0 &&

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { compressImageInBrowser } from '@onlook/utility';
1717
import { observer } from 'mobx-react-lite';
1818
import { useTranslations } from 'next-intl';
1919
import { useEffect, useMemo, useRef, useState } from 'react';
20+
import { v4 as uuidv4 } from 'uuid';
2021
import { validateImageLimit } from '../context-pills/helpers';
2122
import { InputContextPills } from '../context-pills/input-context-pills';
2223
import { type SuggestionsRef } from '../suggestions';
@@ -206,6 +207,7 @@ export const ChatInput = observer(({
206207
const compressedImage = await compressImageInBrowser(file);
207208
const base64URL = compressedImage ?? (event.target?.result as string);
208209
const contextImage: ImageMessageContext = {
210+
id: uuidv4(),
209211
type: MessageContextType.IMAGE,
210212
content: base64URL,
211213
mimeType: file.type,
@@ -258,6 +260,7 @@ export const ChatInput = observer(({
258260
}
259261

260262
const contextImage: ImageMessageContext = {
263+
id: uuidv4(),
261264
type: MessageContextType.IMAGE,
262265
content: screenshotData,
263266
mimeType: mimeType,

apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-messages/message-content/tool-call-display.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { FuzzyEditFileTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, TerminalCommandTool, TypecheckTool, WebSearchTool, WriteFileTool } from '@onlook/ai';
1+
import { FuzzyEditFileTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, TerminalCommandTool, TypecheckTool, UploadImageTool, WebSearchTool, WriteFileTool } from '@onlook/ai';
22
import type { WebSearchResult } from '@onlook/models';
3+
import { Icons } from '@onlook/ui/icons';
34
import type { ToolUIPart } from 'ai';
45
import stripAnsi from 'strip-ansi';
56
import { type z } from 'zod';
@@ -217,6 +218,39 @@ export const ToolCallDisplay = ({
217218
);
218219
}
219220

221+
if (toolName === UploadImageTool.toolName) {
222+
const args = toolPart.input as z.infer<typeof UploadImageTool.parameters> | null;
223+
const result = toolPart.output as string | null;
224+
return (
225+
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-background-secondary">
226+
<div className="flex items-center gap-2">
227+
<Icons.Image className="w-4 h-4" />
228+
<span className="text-sm font-medium">Upload Image</span>
229+
</div>
230+
{args?.image_id && (
231+
<div className="text-xs text-foreground-secondary">
232+
Image ID: {args.image_id}
233+
</div>
234+
)}
235+
{args?.destination_path && (
236+
<div className="text-xs text-foreground-secondary">
237+
Destination: {args.destination_path}
238+
</div>
239+
)}
240+
{args?.filename && (
241+
<div className="text-xs text-foreground-secondary">
242+
Filename: {args.filename}
243+
</div>
244+
)}
245+
{result && (
246+
<div className="text-xs text-green-600 mt-1">
247+
{result}
248+
</div>
249+
)}
250+
</div>
251+
);
252+
}
253+
220254
return (
221255
<ToolCallSimple
222256
toolPart={toolPart}

apps/web/client/src/app/project/[id]/_hooks/use-start-project.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { toast } from '@onlook/ui/sonner';
1515
import { useEffect, useRef, useState } from 'react';
1616
import { useTabActive } from '../_hooks/use-tab-active';
17+
import { v4 as uuidv4 } from 'uuid';
1718

1819
interface ProjectReadyState {
1920
canvas: boolean;
@@ -100,6 +101,7 @@ export const useStartProject = () => {
100101
content: context.content,
101102
mimeType: context.mimeType,
102103
displayName: 'user image',
104+
id: uuidv4(),
103105
}));
104106

105107
const context: MessageContext[] = [...createContext, ...imageContexts];

apps/web/client/src/components/store/editor/api/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { api } from "@/trpc/client";
22
import { makeAutoObservable } from "mobx";
33
import type { EditorEngine } from "../engine";
4+
import type { ChatMessage } from "@onlook/models";
45

56
export class ApiManager {
67
constructor(private editorEngine: EditorEngine) {
@@ -38,4 +39,8 @@ export class ApiManager {
3839
}) {
3940
return await api.utils.scrapeUrl.mutate(input);
4041
}
42+
43+
async getConversationMessages(conversationId: string): Promise<ChatMessage[]> {
44+
return await api.chat.message.getAll.query({ conversationId });
45+
}
4146
}

packages/ai/src/prompt/provider.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ export function getHydratedUserMessage(
100100
prompt += branchPrompt;
101101
}
102102

103+
if (images.length > 0) {
104+
const imageList = images
105+
.map((img, idx) => `${idx + 1}. ${img.displayName} (ID: ${img.id || 'unknown'})`)
106+
.join('\n');
107+
prompt += wrapXml('available-images', imageList);
108+
}
109+
103110
const textContent = parts
104111
.filter((p) => p.type === 'text')
105112
.map((p) => p.text)

packages/ai/src/tools/classes/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export { SearchReplaceEditTool } from './search-replace-edit';
1515
export { SearchReplaceMultiEditFileTool } from './search-replace-multi-edit';
1616
export { TerminalCommandTool } from './terminal-command';
1717
export { TypecheckTool } from './typecheck';
18+
export { UploadImageTool } from './upload-image';
1819
export { WebSearchTool } from './web-search';
1920
export { WriteFileTool } from './write-file';
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Icons } from '@onlook/ui/icons';
2+
import type { EditorEngine } from '@onlook/web-client/src/components/store/editor/engine';
3+
import type { SandboxManager } from '@onlook/web-client/src/components/store/editor/sandbox';
4+
import { MessageContextType, type ImageMessageContext } from '@onlook/models';
5+
import mime from 'mime-lite';
6+
import { v4 as uuidv4 } from 'uuid';
7+
import { z } from 'zod';
8+
import { ClientTool } from '../models/client';
9+
import { BRANCH_ID_SCHEMA } from '../shared/type';
10+
11+
export class UploadImageTool extends ClientTool {
12+
static readonly toolName = 'upload_image';
13+
static readonly description = "Uploads an image from the chat context to the project's file system. Use this tool when the user asks you to save, add, or upload an image to their project. The image will be stored in public/images/ directory by default and can be referenced in code. After uploading, you can use the file path in your code changes.";
14+
static readonly parameters = z.object({
15+
image_id: z
16+
.string()
17+
.describe(
18+
'The unique ID of the image from the available images list',
19+
),
20+
destination_path: z
21+
.string()
22+
.optional()
23+
.describe('Destination path within the project. Defaults to "public/images" if not specified.'),
24+
filename: z
25+
.string()
26+
.optional()
27+
.describe('Custom filename (without extension). If not provided, a UUID will be generated'),
28+
branchId: BRANCH_ID_SCHEMA,
29+
});
30+
static readonly icon = Icons.Image;
31+
32+
async handle(
33+
args: z.infer<typeof UploadImageTool.parameters>,
34+
editorEngine: EditorEngine
35+
): Promise<string> {
36+
try {
37+
const sandbox = editorEngine.branches.getSandboxById(args.branchId);
38+
if (!sandbox) {
39+
throw new Error(`Sandbox not found for branch ID: ${args.branchId}`);
40+
}
41+
42+
// Get the current conversation ID
43+
const conversationId = editorEngine.chat.getCurrentConversationId();
44+
if (!conversationId) {
45+
throw new Error('No active conversation found');
46+
}
47+
48+
// Fetch all messages from the conversation
49+
const messages = await editorEngine.api.getConversationMessages(conversationId);
50+
51+
// Find the image in message contexts by ID, searching from most recent to oldest
52+
let imageContext: ImageMessageContext | null = null;
53+
for (let i = messages.length - 1; i >= 0; i--) {
54+
const message = messages[i];
55+
if (!message || !message.metadata?.context) continue;
56+
57+
const contexts = message.metadata.context;
58+
const imageContexts = contexts.filter(
59+
(ctx) => ctx.type === MessageContextType.IMAGE
60+
);
61+
62+
// Find image by ID
63+
const match = imageContexts.find((ctx) => ctx.id === args.image_id);
64+
65+
if (match) {
66+
imageContext = match;
67+
break;
68+
}
69+
}
70+
71+
if (!imageContext) {
72+
throw new Error(`No image found with ID: ${args.image_id}`);
73+
}
74+
75+
// Upload the image to the sandbox
76+
const fullPath = await this.uploadImageToSandbox(imageContext, args, sandbox);
77+
78+
return `Image "${imageContext.displayName}" uploaded successfully to ${fullPath}`;
79+
} catch (error) {
80+
throw new Error(`Cannot upload image: ${error}`);
81+
}
82+
}
83+
84+
static getLabel(input?: z.infer<typeof UploadImageTool.parameters>): string {
85+
if (input?.filename) {
86+
return 'Uploading image ' + input.filename.substring(0, 20);
87+
}
88+
return 'Uploading image';
89+
}
90+
91+
private async uploadImageToSandbox(
92+
imageContext: ImageMessageContext,
93+
args: z.infer<typeof UploadImageTool.parameters>,
94+
sandbox: SandboxManager
95+
): Promise<string> {
96+
const mimeType = imageContext.mimeType;
97+
const extension = mime.getExtension(mimeType) || 'png';
98+
const filename = args.filename ? `${args.filename}.${extension}` : `${uuidv4()}.${extension}`;
99+
const destinationPath = args.destination_path?.trim() || 'public/images';
100+
const fullPath = `${destinationPath}/${filename}`;
101+
102+
// Extract base64 data from the content (remove data URL prefix if present)
103+
const base64Data = imageContext.content.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, '');
104+
const binaryData = this.base64ToUint8Array(base64Data);
105+
106+
await sandbox.writeFile(fullPath, binaryData);
107+
return fullPath;
108+
}
109+
110+
private base64ToUint8Array(base64: string): Uint8Array {
111+
const binaryString = atob(base64);
112+
const bytes = new Uint8Array(binaryString.length);
113+
for (let i = 0; i < binaryString.length; i++) {
114+
bytes[i] = binaryString.charCodeAt(i);
115+
}
116+
return bytes;
117+
}
118+
}

packages/ai/src/tools/toolset.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
SearchReplaceMultiEditFileTool,
1919
TerminalCommandTool,
2020
TypecheckTool,
21+
UploadImageTool,
2122
WebSearchTool,
2223
WriteFileTool,
2324
} from './classes';
@@ -53,6 +54,7 @@ const editOnlyToolClasses = [
5354
BashEditTool,
5455
SandboxTool,
5556
TerminalCommandTool,
57+
UploadImageTool,
5658
];
5759
const allToolClasses = [...readOnlyToolClasses, ...editOnlyToolClasses];
5860

0 commit comments

Comments
 (0)