Skip to content

Commit f6c15cd

Browse files
committed
refactor(shared): move attachment utilities to shared package
1 parent 7c774e9 commit f6c15cd

4 files changed

Lines changed: 154 additions & 139 deletions

File tree

Lines changed: 11 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import type { AttachmentModality } from "./props";
2-
3-
const DEFAULT_MAX_SIZE = 20 * 1024 * 1024; // 20MB
4-
5-
// ---------------------------------------------------------------------------
6-
// Deprecation warning helpers
7-
// ---------------------------------------------------------------------------
8-
1+
// Re-export utilities from shared
2+
export {
3+
getModalityFromMimeType,
4+
formatFileSize,
5+
exceedsMaxSize,
6+
readFileAsBase64,
7+
generateVideoThumbnail,
8+
matchesAcceptFilter,
9+
} from "@copilotkit/shared";
10+
11+
// Deprecation warning helpers — react-ui specific
912
const suppressedWarnings = new Set<string>();
1013
let globalSuppress = false;
1114

@@ -26,133 +29,3 @@ export function deprecationWarning(key: string, message: string) {
2629
export function suppressDeprecationWarnings() {
2730
globalSuppress = true;
2831
}
29-
30-
/**
31-
* Derive the attachment modality from a MIME type string.
32-
*/
33-
export function getModalityFromMimeType(mimeType: string): AttachmentModality {
34-
if (mimeType.startsWith("image/")) return "image";
35-
if (mimeType.startsWith("audio/")) return "audio";
36-
if (mimeType.startsWith("video/")) return "video";
37-
return "document";
38-
}
39-
40-
/**
41-
* Format a byte count as a human-readable file size string.
42-
*/
43-
export function formatFileSize(bytes: number): string {
44-
if (bytes < 1024) return `${bytes} B`;
45-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
46-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
47-
}
48-
49-
/**
50-
* Check if a file exceeds the maximum allowed size.
51-
*/
52-
export function exceedsMaxSize(
53-
file: File,
54-
maxSize: number = DEFAULT_MAX_SIZE,
55-
): boolean {
56-
return file.size > maxSize;
57-
}
58-
59-
/**
60-
* Read a File as a base64 string (without the data URL prefix).
61-
*/
62-
export function readFileAsBase64(file: File): Promise<string> {
63-
return new Promise((resolve, reject) => {
64-
const reader = new FileReader();
65-
reader.onload = (e) => {
66-
const result = e.target?.result as string;
67-
const base64 = result?.split(",")[1];
68-
if (base64) {
69-
resolve(base64);
70-
} else {
71-
reject(new Error("Failed to read file as base64"));
72-
}
73-
};
74-
reader.onerror = reject;
75-
reader.readAsDataURL(file);
76-
});
77-
}
78-
79-
/**
80-
* Generate a thumbnail data URL from a video file by capturing the first frame.
81-
* Returns undefined if thumbnail generation fails.
82-
*/
83-
export function generateVideoThumbnail(
84-
file: File,
85-
): Promise<string | undefined> {
86-
return new Promise((resolve) => {
87-
let resolved = false;
88-
const video = document.createElement("video");
89-
const canvas = document.createElement("canvas");
90-
const url = URL.createObjectURL(file);
91-
92-
const cleanup = (result: string | undefined) => {
93-
if (resolved) return;
94-
resolved = true;
95-
URL.revokeObjectURL(url);
96-
resolve(result);
97-
};
98-
99-
const timeout = setTimeout(() => {
100-
console.warn(
101-
`[CopilotKit] generateVideoThumbnail: timed out for file "${file.name}"`,
102-
);
103-
cleanup(undefined);
104-
}, 10000);
105-
106-
video.preload = "metadata";
107-
video.muted = true;
108-
video.playsInline = true;
109-
110-
video.onloadeddata = () => {
111-
video.currentTime = 0.1;
112-
};
113-
114-
video.onseeked = () => {
115-
clearTimeout(timeout);
116-
canvas.width = video.videoWidth;
117-
canvas.height = video.videoHeight;
118-
const ctx = canvas.getContext("2d");
119-
if (ctx) {
120-
ctx.drawImage(video, 0, 0);
121-
const thumbnail = canvas.toDataURL("image/jpeg", 0.7);
122-
cleanup(thumbnail);
123-
} else {
124-
console.warn(
125-
"[CopilotKit] generateVideoThumbnail: could not get 2d canvas context",
126-
);
127-
cleanup(undefined);
128-
}
129-
};
130-
131-
video.onerror = () => {
132-
clearTimeout(timeout);
133-
console.warn(
134-
`[CopilotKit] generateVideoThumbnail: video element error for file "${file.name}"`,
135-
);
136-
cleanup(undefined);
137-
};
138-
139-
video.src = url;
140-
});
141-
}
142-
143-
/**
144-
* Check if a file's MIME type matches an accept filter string.
145-
* Handles wildcards like "image/*" and comma-separated lists.
146-
*/
147-
export function matchesAcceptFilter(file: File, accept: string): boolean {
148-
if (!accept || accept === "*/*") return true;
149-
150-
const filters = accept.split(",").map((f) => f.trim());
151-
return filters.some((filter) => {
152-
if (filter.endsWith("/*")) {
153-
const prefix = filter.slice(0, -2);
154-
return file.type.startsWith(prefix + "/");
155-
}
156-
return file.type === filter;
157-
});
158-
}

packages/react-ui/src/components/chat/__tests__/attachment-utils.test.ts renamed to packages/shared/src/attachments/__tests__/utils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
formatFileSize,
44
exceedsMaxSize,
55
matchesAcceptFilter,
6-
} from "../attachment-utils";
6+
} from "../utils";
77

88
// ---------------------------------------------------------------------------
99
// Helpers

packages/shared/src/attachments/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,12 @@ export type {
33
Attachment,
44
AttachmentModality,
55
} from "./types";
6+
7+
export {
8+
getModalityFromMimeType,
9+
formatFileSize,
10+
exceedsMaxSize,
11+
readFileAsBase64,
12+
generateVideoThumbnail,
13+
matchesAcceptFilter,
14+
} from "./utils";
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type { AttachmentModality } from "./types";
2+
3+
const DEFAULT_MAX_SIZE = 20 * 1024 * 1024; // 20MB
4+
5+
/**
6+
* Derive the attachment modality from a MIME type string.
7+
*/
8+
export function getModalityFromMimeType(mimeType: string): AttachmentModality {
9+
if (mimeType.startsWith("image/")) return "image";
10+
if (mimeType.startsWith("audio/")) return "audio";
11+
if (mimeType.startsWith("video/")) return "video";
12+
return "document";
13+
}
14+
15+
/**
16+
* Format a byte count as a human-readable file size string.
17+
*/
18+
export function formatFileSize(bytes: number): string {
19+
if (bytes < 1024) return `${bytes} B`;
20+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
21+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
22+
}
23+
24+
/**
25+
* Check if a file exceeds the maximum allowed size.
26+
*/
27+
export function exceedsMaxSize(
28+
file: File,
29+
maxSize: number = DEFAULT_MAX_SIZE,
30+
): boolean {
31+
return file.size > maxSize;
32+
}
33+
34+
/**
35+
* Read a File as a base64 string (without the data URL prefix).
36+
*/
37+
export function readFileAsBase64(file: File): Promise<string> {
38+
return new Promise((resolve, reject) => {
39+
const reader = new FileReader();
40+
reader.onload = (e) => {
41+
const result = e.target?.result as string;
42+
const base64 = result?.split(",")[1];
43+
if (base64) {
44+
resolve(base64);
45+
} else {
46+
reject(new Error("Failed to read file as base64"));
47+
}
48+
};
49+
reader.onerror = reject;
50+
reader.readAsDataURL(file);
51+
});
52+
}
53+
54+
/**
55+
* Generate a thumbnail data URL from a video file by capturing the first frame.
56+
* Returns undefined if thumbnail generation fails.
57+
*/
58+
export function generateVideoThumbnail(
59+
file: File,
60+
): Promise<string | undefined> {
61+
return new Promise((resolve) => {
62+
let resolved = false;
63+
const video = document.createElement("video");
64+
const canvas = document.createElement("canvas");
65+
const url = URL.createObjectURL(file);
66+
67+
const cleanup = (result: string | undefined) => {
68+
if (resolved) return;
69+
resolved = true;
70+
URL.revokeObjectURL(url);
71+
resolve(result);
72+
};
73+
74+
const timeout = setTimeout(() => {
75+
console.warn(
76+
`[CopilotKit] generateVideoThumbnail: timed out for file "${file.name}"`,
77+
);
78+
cleanup(undefined);
79+
}, 10000);
80+
81+
video.preload = "metadata";
82+
video.muted = true;
83+
video.playsInline = true;
84+
85+
video.onloadeddata = () => {
86+
video.currentTime = 0.1;
87+
};
88+
89+
video.onseeked = () => {
90+
clearTimeout(timeout);
91+
canvas.width = video.videoWidth;
92+
canvas.height = video.videoHeight;
93+
const ctx = canvas.getContext("2d");
94+
if (ctx) {
95+
ctx.drawImage(video, 0, 0);
96+
const thumbnail = canvas.toDataURL("image/jpeg", 0.7);
97+
cleanup(thumbnail);
98+
} else {
99+
console.warn(
100+
"[CopilotKit] generateVideoThumbnail: could not get 2d canvas context",
101+
);
102+
cleanup(undefined);
103+
}
104+
};
105+
106+
video.onerror = () => {
107+
clearTimeout(timeout);
108+
console.warn(
109+
`[CopilotKit] generateVideoThumbnail: video element error for file "${file.name}"`,
110+
);
111+
cleanup(undefined);
112+
};
113+
114+
video.src = url;
115+
});
116+
}
117+
118+
/**
119+
* Check if a file's MIME type matches an accept filter string.
120+
* Handles wildcards like "image/*" and comma-separated lists.
121+
*/
122+
export function matchesAcceptFilter(file: File, accept: string): boolean {
123+
if (!accept || accept === "*/*") return true;
124+
125+
const filters = accept.split(",").map((f) => f.trim());
126+
return filters.some((filter) => {
127+
if (filter.endsWith("/*")) {
128+
const prefix = filter.slice(0, -2);
129+
return file.type.startsWith(prefix + "/");
130+
}
131+
return file.type === filter;
132+
});
133+
}

0 commit comments

Comments
 (0)