@convex-dev/agent 0.1.6 → 0.1.7-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +23 -12
  2. package/dist/commonjs/client/_generated/_ignore.d.ts +1 -0
  3. package/dist/commonjs/client/_generated/_ignore.d.ts.map +1 -0
  4. package/dist/commonjs/client/_generated/_ignore.js +3 -0
  5. package/dist/commonjs/client/_generated/_ignore.js.map +1 -0
  6. package/dist/commonjs/client/files.d.ts +57 -0
  7. package/dist/commonjs/client/files.d.ts.map +1 -0
  8. package/dist/commonjs/client/files.js +124 -0
  9. package/dist/commonjs/client/files.js.map +1 -0
  10. package/dist/commonjs/client/index.d.ts +487 -318
  11. package/dist/commonjs/client/index.d.ts.map +1 -1
  12. package/dist/commonjs/client/index.js +652 -550
  13. package/dist/commonjs/client/index.js.map +1 -1
  14. package/dist/commonjs/client/types.d.ts +35 -5
  15. package/dist/commonjs/client/types.d.ts.map +1 -1
  16. package/dist/commonjs/component/_generated/api.d.ts +47 -711
  17. package/dist/commonjs/component/files.d.ts +66 -21
  18. package/dist/commonjs/component/files.d.ts.map +1 -1
  19. package/dist/commonjs/component/files.js +71 -26
  20. package/dist/commonjs/component/files.js.map +1 -1
  21. package/dist/commonjs/component/messages.d.ts +31 -499
  22. package/dist/commonjs/component/messages.d.ts.map +1 -1
  23. package/dist/commonjs/component/messages.js +45 -125
  24. package/dist/commonjs/component/messages.js.map +1 -1
  25. package/dist/commonjs/component/schema.d.ts +949 -3785
  26. package/dist/commonjs/component/schema.d.ts.map +1 -1
  27. package/dist/commonjs/component/schema.js +9 -25
  28. package/dist/commonjs/component/schema.js.map +1 -1
  29. package/dist/commonjs/component/streams.d.ts +3 -3
  30. package/dist/commonjs/mapping.d.ts +28 -19
  31. package/dist/commonjs/mapping.d.ts.map +1 -1
  32. package/dist/commonjs/mapping.js +136 -81
  33. package/dist/commonjs/mapping.js.map +1 -1
  34. package/dist/commonjs/react/toUIMessages.d.ts.map +1 -1
  35. package/dist/commonjs/react/toUIMessages.js +12 -6
  36. package/dist/commonjs/react/toUIMessages.js.map +1 -1
  37. package/dist/commonjs/react/usePaginatedQuery.d.ts.map +1 -1
  38. package/dist/commonjs/react/usePaginatedQuery.js +0 -19
  39. package/dist/commonjs/react/usePaginatedQuery.js.map +1 -1
  40. package/dist/commonjs/validators.d.ts +851 -3388
  41. package/dist/commonjs/validators.d.ts.map +1 -1
  42. package/dist/commonjs/validators.js +11 -37
  43. package/dist/commonjs/validators.js.map +1 -1
  44. package/dist/commonjs.tsbuildinfo +1 -1
  45. package/dist/esm/client/_generated/_ignore.d.ts +1 -0
  46. package/dist/esm/client/_generated/_ignore.d.ts.map +1 -0
  47. package/dist/esm/client/_generated/_ignore.js +3 -0
  48. package/dist/esm/client/_generated/_ignore.js.map +1 -0
  49. package/dist/esm/client/files.d.ts +57 -0
  50. package/dist/esm/client/files.d.ts.map +1 -0
  51. package/dist/esm/client/files.js +124 -0
  52. package/dist/esm/client/files.js.map +1 -0
  53. package/dist/esm/client/index.d.ts +487 -318
  54. package/dist/esm/client/index.d.ts.map +1 -1
  55. package/dist/esm/client/index.js +652 -550
  56. package/dist/esm/client/index.js.map +1 -1
  57. package/dist/esm/client/types.d.ts +35 -5
  58. package/dist/esm/client/types.d.ts.map +1 -1
  59. package/dist/esm/component/_generated/api.d.ts +47 -711
  60. package/dist/esm/component/files.d.ts +66 -21
  61. package/dist/esm/component/files.d.ts.map +1 -1
  62. package/dist/esm/component/files.js +71 -26
  63. package/dist/esm/component/files.js.map +1 -1
  64. package/dist/esm/component/messages.d.ts +31 -499
  65. package/dist/esm/component/messages.d.ts.map +1 -1
  66. package/dist/esm/component/messages.js +45 -125
  67. package/dist/esm/component/messages.js.map +1 -1
  68. package/dist/esm/component/schema.d.ts +949 -3785
  69. package/dist/esm/component/schema.d.ts.map +1 -1
  70. package/dist/esm/component/schema.js +9 -25
  71. package/dist/esm/component/schema.js.map +1 -1
  72. package/dist/esm/component/streams.d.ts +3 -3
  73. package/dist/esm/mapping.d.ts +28 -19
  74. package/dist/esm/mapping.d.ts.map +1 -1
  75. package/dist/esm/mapping.js +136 -81
  76. package/dist/esm/mapping.js.map +1 -1
  77. package/dist/esm/react/toUIMessages.d.ts.map +1 -1
  78. package/dist/esm/react/toUIMessages.js +12 -6
  79. package/dist/esm/react/toUIMessages.js.map +1 -1
  80. package/dist/esm/react/usePaginatedQuery.d.ts.map +1 -1
  81. package/dist/esm/react/usePaginatedQuery.js +0 -19
  82. package/dist/esm/react/usePaginatedQuery.js.map +1 -1
  83. package/dist/esm/validators.d.ts +851 -3388
  84. package/dist/esm/validators.d.ts.map +1 -1
  85. package/dist/esm/validators.js +11 -37
  86. package/dist/esm/validators.js.map +1 -1
  87. package/dist/esm.tsbuildinfo +1 -1
  88. package/package.json +11 -9
  89. package/src/client/_generated/_ignore.ts +1 -0
  90. package/src/client/files.ts +167 -0
  91. package/src/client/index.test.ts +127 -1
  92. package/src/client/index.ts +970 -812
  93. package/src/client/setup.test.ts +29 -0
  94. package/src/client/types.ts +41 -3
  95. package/src/component/_generated/api.d.ts +47 -711
  96. package/src/component/files.test.ts +130 -0
  97. package/src/component/files.ts +83 -27
  98. package/src/component/messages.test.ts +2 -1
  99. package/src/component/messages.ts +47 -137
  100. package/src/component/schema.ts +8 -28
  101. package/src/mapping.test.ts +163 -0
  102. package/src/mapping.ts +173 -103
  103. package/src/react/deltas.test.ts +4 -1
  104. package/src/react/toUIMessages.ts +12 -6
  105. package/src/react/usePaginatedQuery.ts +0 -31
  106. package/src/validators.ts +33 -47
@@ -0,0 +1,163 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ guessMimeType,
4
+ serializeDataOrUrl,
5
+ deserializeUrl,
6
+ serializeMessage,
7
+ deserializeMessage,
8
+ serializeContent,
9
+ } from "./mapping";
10
+ import { api } from "./component/_generated/api.js";
11
+ import type { AgentComponent, ActionCtx } from "./client/types";
12
+ import { vMessage } from "./validators";
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import type { SerializedContent } from "./mapping";
16
+ import { validate } from "convex-helpers/validators";
17
+
18
+ const testAssetsDir = path.join(__dirname, "../test-assets");
19
+ const testFiles = [
20
+ "book.svg",
21
+ "bump.jpeg",
22
+ "stack.png",
23
+ "favicon.ico",
24
+ "convex-logo.svg",
25
+ "stack-light@3x.webp",
26
+ ];
27
+
28
+ function fileToArrayBuffer(filePath: string): ArrayBuffer {
29
+ const buf = fs.readFileSync(filePath);
30
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
31
+ }
32
+
33
+ describe("mapping", () => {
34
+ test("infers correct mimeType for all test-assets", () => {
35
+ const expected: { [key: string]: string } = {
36
+ "book.svg": "image/svg+xml", // <svg
37
+ "bump.jpeg": "image/jpeg",
38
+ "stack.png": "image/png",
39
+ "favicon.ico": "application/octet-stream", // fallback for ico
40
+ "convex-logo.svg": "image/svg+xml", // <?xm
41
+ "stack-light@3x.webp": "image/webp",
42
+ "cat.gif": "image/gif",
43
+ };
44
+ for (const file of testFiles) {
45
+ const ab = fileToArrayBuffer(path.join(testAssetsDir, file));
46
+ const mime = guessMimeType(ab);
47
+ expect(mime).toBe(expected[file]);
48
+ }
49
+ });
50
+
51
+ test("turns Uint8Array into ArrayBuffer and round-trips", () => {
52
+ const arr = new Uint8Array([1, 2, 3, 4, 5]);
53
+ // serializeDataOrUrl should return the same ArrayBuffer
54
+ const ser = serializeDataOrUrl(arr);
55
+ expect(ser).toBeInstanceOf(ArrayBuffer);
56
+ expect(new Uint8Array(ser as ArrayBuffer)).toEqual(arr);
57
+ // deserializeUrl should return the same ArrayBuffer
58
+ const deser = deserializeUrl(ser);
59
+ expect(deser).toBeInstanceOf(ArrayBuffer);
60
+ expect(new Uint8Array(deser as ArrayBuffer)).toEqual(arr);
61
+ });
62
+
63
+ test("round-trip serialize/deserialize message", async () => {
64
+ const message = {
65
+ role: "user" as const,
66
+ content: "hello world",
67
+ providerOptions: {},
68
+ };
69
+ // Fake ctx and component
70
+ const ctx = {
71
+ runAction: async () => undefined,
72
+ runMutation: async () => undefined,
73
+ storage: {
74
+ store: async () => "storageId",
75
+ getUrl: async () => "https://example.com/file",
76
+ delete: async () => undefined,
77
+ },
78
+ } as unknown as ActionCtx;
79
+ const component = api as unknown as AgentComponent;
80
+ const { message: ser } = await serializeMessage(ctx, component, message);
81
+ // Use is for type validation
82
+ expect(validate(vMessage, ser)).toBeTruthy();
83
+ const round = deserializeMessage(ser);
84
+ expect(round).toEqual(message);
85
+ });
86
+
87
+ test("saving files returns fileIds when too big", async () => {
88
+ // Make a big file
89
+ const bigArr = new Uint8Array(1024 * 65).fill(1);
90
+ const ab = bigArr.buffer.slice(
91
+ bigArr.byteOffset,
92
+ bigArr.byteOffset + bigArr.byteLength
93
+ );
94
+ let called = false;
95
+ const ctx = {
96
+ runAction: async () => undefined,
97
+ runMutation: async (_fn: unknown, _args: unknown) => {
98
+ called = true;
99
+ return { fileId: "file-123", storageId: "storage-123" };
100
+ },
101
+ storage: {
102
+ store: async () => "storageId",
103
+ getUrl: async () => "https://example.com/file",
104
+ delete: async () => undefined,
105
+ },
106
+ } as unknown as ActionCtx;
107
+ const component = api as unknown as AgentComponent;
108
+ const content = [
109
+ {
110
+ type: "file" as const,
111
+ data: ab,
112
+ filename: "bigfile.bin",
113
+ mimeType: "application/octet-stream",
114
+ providerOptions: {},
115
+ },
116
+ ];
117
+ const { content: ser, fileIds } = await serializeContent(
118
+ ctx,
119
+ component,
120
+ content
121
+ );
122
+ expect(called).toBe(true);
123
+ expect(fileIds).toEqual(["file-123"]);
124
+ // Should have replaced data with a URL
125
+ const serArr = ser as SerializedContent;
126
+ expect(typeof (serArr as { data: unknown }[])[0].data).toBe("string");
127
+ expect((serArr as { data: unknown }[])[0].data as string).toMatch(
128
+ /^https?:\/\//
129
+ );
130
+ });
131
+
132
+ test("sanity: fileIds are not returned for small files", async () => {
133
+ const arr = new Uint8Array([1, 2, 3, 4, 5]);
134
+ const ab = arr.buffer.slice(
135
+ arr.byteOffset,
136
+ arr.byteOffset + arr.byteLength
137
+ );
138
+ const ctx = {
139
+ runAction: async () => undefined,
140
+ runMutation: async () => ({
141
+ fileId: "file-123",
142
+ storageId: "storage-123",
143
+ }),
144
+ storage: {
145
+ store: async () => "storageId",
146
+ getUrl: async () => "https://example.com/file",
147
+ delete: async () => undefined,
148
+ },
149
+ } as unknown as ActionCtx;
150
+ const component = api as unknown as AgentComponent;
151
+ const content = [
152
+ {
153
+ type: "file" as const,
154
+ data: ab,
155
+ filename: "smallfile.bin",
156
+ mimeType: "application/octet-stream",
157
+ providerOptions: {},
158
+ },
159
+ ];
160
+ const { fileIds } = await serializeContent(ctx, component, content);
161
+ expect(fileIds).toBeUndefined();
162
+ });
163
+ });
package/src/mapping.ts CHANGED
@@ -6,18 +6,20 @@ import {
6
6
  type AssistantContent,
7
7
  type CoreMessage,
8
8
  type DataContent,
9
+ type FilePart,
9
10
  type GenerateObjectResult,
11
+ type ImagePart,
10
12
  type StepResult,
11
13
  type ToolContent,
12
14
  type ToolSet,
13
15
  type UserContent,
14
16
  } from "ai";
15
17
  import { assert } from "convex-helpers";
16
- import type {
17
- MessageWithMetadata,
18
- Step,
19
- StepWithMessagesWithMetadata,
20
- } from "./validators";
18
+ import type { MessageWithMetadata } from "./validators";
19
+ import type { ActionCtx, AgentComponent } from "./client/types.js";
20
+ import type { RunMutationCtx } from "./client/types.js";
21
+ import { MAX_FILE_SIZE, storeFile } from "./client/files.js";
22
+
21
23
 
22
24
  export type AIMessageWithoutId = Omit<AIMessage, "id">;
23
25
 
@@ -37,23 +39,26 @@ export type SerializedContent = SerializeUrlsAndUint8Arrays<Content>;
37
39
 
38
40
  export type SerializedMessage = SerializeUrlsAndUint8Arrays<CoreMessage>;
39
41
 
40
- export function serializeMessage(
42
+ export async function serializeMessage(
43
+ ctx: ActionCtx | RunMutationCtx,
44
+ component: AgentComponent,
41
45
  messageWithId: CoreMessage & { id?: string }
42
- ): SerializedMessage {
46
+ ): Promise<{ message: SerializedMessage; fileIds?: string[] }> {
43
47
  const { id: _, experimental_providerMetadata, ...message } = messageWithId;
44
- const content = message.content;
48
+ const { content, fileIds } = await serializeContent(
49
+ ctx,
50
+ component,
51
+ message.content
52
+ );
45
53
  return {
46
- // for backwards compatibility
47
- providerOptions: experimental_providerMetadata,
48
- ...message,
49
- content: serializeContent(content),
50
- } as SerializedMessage;
51
- }
52
-
53
- export function serializeMessageWithId(
54
- messageWithId: CoreMessage & { id?: string }
55
- ): { message: SerializedMessage; id: string | undefined } {
56
- return { message: serializeMessage(messageWithId), id: messageWithId.id };
54
+ message: {
55
+ // for backwards compatibility
56
+ providerOptions: experimental_providerMetadata,
57
+ ...message,
58
+ content,
59
+ } as SerializedMessage,
60
+ fileIds,
61
+ };
57
62
  }
58
63
 
59
64
  export function deserializeMessage(message: SerializedMessage): CoreMessage {
@@ -63,29 +68,12 @@ export function deserializeMessage(message: SerializedMessage): CoreMessage {
63
68
  } as CoreMessage;
64
69
  }
65
70
 
66
- export function serializeStep<TOOLS extends ToolSet>(
67
- step: StepResult<TOOLS>
68
- ): Step {
69
- const content = step.response?.messages.map((message) => {
70
- return serializeMessageWithId(message);
71
- });
72
- const timestamp = step.response?.timestamp.getTime();
73
- const response = {
74
- ...step.response,
75
- messages: content,
76
- timestamp,
77
- headers: {}, // these are large and low value
78
- };
79
- return {
80
- ...step,
81
- response,
82
- };
83
- }
84
-
85
- export function serializeNewMessagesInStep<TOOLS extends ToolSet>(
71
+ export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
72
+ ctx: ActionCtx,
73
+ component: AgentComponent,
86
74
  step: StepResult<TOOLS>,
87
75
  metadata: { model: string; provider: string }
88
- ): MessageWithMetadata[] {
76
+ ): Promise<MessageWithMetadata[]> {
89
77
  // If there are tool results, there's another message with the tool results
90
78
  // ref: https://github.com/vercel/ai/blob/main/packages/ai/core/generate-text/to-response-messages.ts
91
79
  const assistantFields = {
@@ -101,24 +89,25 @@ export function serializeNewMessagesInStep<TOOLS extends ToolSet>(
101
89
  const toolFields = {
102
90
  sources: step.sources,
103
91
  };
104
- const messages: MessageWithMetadata[] = (
105
- step.toolResults.length > 0
92
+ const messages: MessageWithMetadata[] = await Promise.all(
93
+ (step.toolResults.length > 0
106
94
  ? step.response.messages.slice(-2)
107
95
  : step.response.messages.slice(-1)
108
- ).map(
109
- (message): MessageWithMetadata => ({
110
- message: serializeMessage(message),
111
- // Let's not store the ID by default here. It's being generated internally
112
- // and not referenced elsewhere that we know of.
113
- // id: message.id,
114
- ...(message.role === "tool" ? toolFields : assistantFields),
115
- text: step.text,
116
- // fileId: message.fileId,
117
- files: step.files.map((file) => ({
118
- mimeType: file.mimeType,
119
- data: serializeDataOrUrl(file.uint8Array ?? file.base64),
120
- // TODO: if the file is big, store it and populate url, fileId
121
- })),
96
+ ).map(async (messageWithId): Promise<MessageWithMetadata> => {
97
+ const { message, fileIds } = await serializeMessage(
98
+ ctx,
99
+ component,
100
+ messageWithId
101
+ );
102
+ return {
103
+ message,
104
+ // Let's not store the ID by default here. It's being generated internally
105
+ // and not referenced elsewhere that we know of.
106
+ // id: message.id,
107
+ ...(message.role === "tool" ? toolFields : assistantFields),
108
+ text: step.text,
109
+ fileIds,
110
+ };
122
111
  })
123
112
  );
124
113
  return messages;
@@ -127,13 +116,18 @@ export function serializeNewMessagesInStep<TOOLS extends ToolSet>(
127
116
  export function serializeObjectResult(
128
117
  result: GenerateObjectResult<unknown>,
129
118
  metadata: { model: string; provider: string }
130
- ): StepWithMessagesWithMetadata {
119
+ ): { messages: MessageWithMetadata[] } {
131
120
  const text = JSON.stringify(result.object);
132
121
 
122
+ const message = {
123
+ role: "assistant" as const,
124
+ content: text,
125
+ id: result.response.id,
126
+ };
133
127
  return {
134
128
  messages: [
135
129
  {
136
- message: { role: "assistant" as const, content: text },
130
+ message,
137
131
  id: result.response.id,
138
132
  model: metadata.model,
139
133
  provider: metadata.provider,
@@ -144,50 +138,60 @@ export function serializeObjectResult(
144
138
  warnings: result.warnings,
145
139
  },
146
140
  ],
147
- step: {
148
- text,
149
- isContinued: false,
150
- stepType: "initial",
151
- toolCalls: [],
152
- toolResults: [],
153
- usage: result.usage,
154
- warnings: result.warnings,
155
- finishReason: result.finishReason,
156
- providerMetadata: result.providerMetadata,
157
- request: result.request,
158
- response: {
159
- ...result.response,
160
- timestamp: result.response.timestamp.getTime(),
161
- messages: [
162
- serializeMessageWithId({
163
- role: "assistant" as const,
164
- content: text,
165
- id: result.response.id,
166
- }),
167
- ],
168
- },
169
- },
170
141
  };
171
142
  }
172
143
 
173
- export function serializeContent(content: Content): SerializedContent {
144
+ export async function serializeContent(
145
+ ctx: ActionCtx | RunMutationCtx,
146
+ component: AgentComponent,
147
+ content: Content
148
+ ): Promise<{ content: SerializedContent; fileIds?: string[] }> {
174
149
  if (typeof content === "string") {
175
- return content;
150
+ return { content };
176
151
  }
177
- const serialized = content.map(
178
- ({ experimental_providerMetadata, ...rest }) => {
152
+ const fileIds: string[] = [];
153
+ const serialized = await Promise.all(
154
+ content.map(async ({ experimental_providerMetadata, ...rest }) => {
179
155
  const part = { providerOptions: experimental_providerMetadata, ...rest };
180
156
  switch (part.type) {
181
- case "image":
182
- return { ...part, image: serializeDataOrUrl(part.image) };
183
- case "file":
184
- return { ...part, data: serializeDataOrUrl(part.data) };
157
+ case "image": {
158
+ let image = serializeDataOrUrl(part.image);
159
+ if (
160
+ image instanceof ArrayBuffer &&
161
+ image.byteLength > MAX_FILE_SIZE
162
+ ) {
163
+ const { file } = await storeFile(
164
+ ctx,
165
+ component,
166
+ new Blob([image], { type: part.mimeType || guessMimeType(image) })
167
+ );
168
+ image = file.url;
169
+ fileIds.push(file.fileId);
170
+ }
171
+ return { ...part, image };
172
+ }
173
+ case "file": {
174
+ let data = serializeDataOrUrl(part.data);
175
+ if (data instanceof ArrayBuffer && data.byteLength > MAX_FILE_SIZE) {
176
+ const { file } = await storeFile(
177
+ ctx,
178
+ component,
179
+ new Blob([data], { type: part.mimeType })
180
+ );
181
+ data = file.url;
182
+ fileIds.push(file.fileId);
183
+ }
184
+ return { ...part, data };
185
+ }
185
186
  default:
186
187
  return part;
187
188
  }
188
- }
189
+ })
189
190
  );
190
- return serialized as SerializedContent;
191
+ return {
192
+ content: serialized as SerializedContent,
193
+ fileIds: fileIds.length > 0 ? fileIds : undefined,
194
+ };
191
195
  }
192
196
 
193
197
  export function deserializeContent(content: SerializedContent): Content {
@@ -206,8 +210,74 @@ export function deserializeContent(content: SerializedContent): Content {
206
210
  }) as Content;
207
211
  }
208
212
 
209
- // TODO: store in file storage if it's big
210
- function serializeDataOrUrl(
213
+ /**
214
+ * Return a best-guess MIME type based on the magic-number signature
215
+ * found at the start of an ArrayBuffer.
216
+ *
217
+ * @param buf – the source ArrayBuffer
218
+ * @returns the detected MIME type, or `"application/octet-stream"` if unknown
219
+ */
220
+ export function guessMimeType(buf: ArrayBuffer | string): string {
221
+ if (typeof buf === "string") {
222
+ if (buf.match(/^data:\w+\/\w+;base64/)) {
223
+ return buf.split(";")[0].split(":")[1]!;
224
+ }
225
+ return "text/plain";
226
+ }
227
+ if (buf.byteLength < 4) return "application/octet-stream";
228
+
229
+ // Read the first 12 bytes (enough for all signatures below)
230
+ const bytes = new Uint8Array(buf.slice(0, 12));
231
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
232
+
233
+ // Helper so we can look at only the needed prefix
234
+ const startsWith = (sig: string) => hex.startsWith(sig.toLowerCase());
235
+
236
+ // --- image formats ---
237
+ if (startsWith("89504e47")) return "image/png"; // PNG - 89 50 4E 47
238
+ if (
239
+ startsWith("ffd8ffdb") ||
240
+ startsWith("ffd8ffe0") ||
241
+ startsWith("ffd8ffee") ||
242
+ startsWith("ffd8ffe1")
243
+ )
244
+ return "image/jpeg"; // JPEG
245
+ if (startsWith("47494638")) return "image/gif"; // GIF
246
+ if (startsWith("424d")) return "image/bmp"; // BMP
247
+ if (startsWith("52494646") && hex.substr(16, 8) === "57454250")
248
+ return "image/webp"; // WEBP (RIFF....WEBP)
249
+ if (startsWith("49492a00")) return "image/tiff"; // TIFF
250
+ // <svg in hex is 3c 3f 78 6d 6c
251
+ if (startsWith("3c737667")) return "image/svg+xml"; // <svg
252
+ if (startsWith("3c3f786d")) return "image/svg+xml"; // <?xm
253
+
254
+ // --- audio/video ---
255
+ if (startsWith("494433")) return "audio/mpeg"; // MP3 (ID3)
256
+ if (startsWith("000001ba") || startsWith("000001b3")) return "video/mpeg"; // MPEG container
257
+ if (startsWith("1a45dfa3")) return "video/webm"; // WEBM / Matroska
258
+ if (startsWith("00000018") && hex.substr(16, 8) === "66747970")
259
+ return "video/mp4"; // MP4
260
+ if (startsWith("4f676753")) return "audio/ogg"; // OGG / Opus
261
+
262
+ // --- documents & archives ---
263
+ if (startsWith("25504446")) return "application/pdf"; // PDF
264
+ if (
265
+ startsWith("504b0304") ||
266
+ startsWith("504b0506") ||
267
+ startsWith("504b0708")
268
+ )
269
+ return "application/zip"; // ZIP / DOCX / PPTX / XLSX / EPUB
270
+ if (startsWith("52617221")) return "application/x-rar-compressed"; // RAR
271
+ if (startsWith("7f454c46")) return "application/x-elf"; // ELF binaries
272
+ if (startsWith("1f8b08")) return "application/gzip"; // GZIP
273
+ if (startsWith("425a68")) return "application/x-bzip2"; // BZIP2
274
+ if (startsWith("3c3f786d6c")) return "application/xml"; // XML
275
+
276
+ // Plain text, JSON and others are trickier—fallback:
277
+ return "application/octet-stream";
278
+ }
279
+
280
+ export function serializeDataOrUrl(
211
281
  dataOrUrl: DataContent | URL
212
282
  ): ArrayBuffer | string {
213
283
  if (typeof dataOrUrl === "string") {
@@ -225,7 +295,9 @@ function serializeDataOrUrl(
225
295
  ) as ArrayBuffer;
226
296
  }
227
297
 
228
- function deserializeUrl(urlOrString: string | ArrayBuffer): URL | DataContent {
298
+ export function deserializeUrl(
299
+ urlOrString: string | ArrayBuffer
300
+ ): URL | DataContent {
229
301
  if (typeof urlOrString === "string") {
230
302
  if (
231
303
  urlOrString.startsWith("http://") ||
@@ -238,18 +310,16 @@ function deserializeUrl(urlOrString: string | ArrayBuffer): URL | DataContent {
238
310
  return urlOrString;
239
311
  }
240
312
 
241
- export function toUIFilePart(file: {
242
- data?: ArrayBuffer | string;
243
- url?: string;
244
- mimeType: string;
245
- }): FileUIPart {
313
+ export function toUIFilePart(part: ImagePart | FilePart): FileUIPart {
314
+ const dataOrUrl = serializeDataOrUrl(
315
+ part.type === "image" ? part.image : part.data
316
+ );
317
+
246
318
  return {
247
319
  type: "file",
248
320
  data:
249
- file.data instanceof ArrayBuffer
250
- ? encodeBase64(file.data)
251
- : file.url ?? file.data ?? "",
252
- mimeType: file.mimeType,
321
+ dataOrUrl instanceof ArrayBuffer ? encodeBase64(dataOrUrl) : dataOrUrl,
322
+ mimeType: part.mimeType ?? guessMimeType(dataOrUrl),
253
323
  };
254
324
  }
255
325
 
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { mergeDeltas, applyDeltasToStreamMessage } from "./deltas";
3
3
  import type { StreamMessage, StreamDelta, TextStreamPart } from "../validators";
4
+ import { omit } from "convex-helpers";
4
5
 
5
6
  function makeStreamMessage(
6
7
  streamId: string,
@@ -283,7 +284,9 @@ describe("mergeDeltas", () => {
283
284
  [],
284
285
  deltas
285
286
  );
286
- expect(messages1).toEqual(messages2);
287
+ expect(messages1.map((m) => omit(m, ["_creationTime"]))).toEqual(
288
+ messages2.map((m) => omit(m, ["_creationTime"]))
289
+ );
287
290
  expect(streams1).toEqual(streams2);
288
291
  expect(changed1).toBe(changed2);
289
292
  // Inputs should remain unchanged
@@ -43,9 +43,14 @@ export function toUIMessages(
43
43
  if (text) {
44
44
  parts.push({ type: "text", text });
45
45
  }
46
- if (message.files) {
47
- parts.push(...message.files.map(toUIFilePart));
48
- }
46
+ nonStringContent.forEach((contentPart) => {
47
+ switch (contentPart.type) {
48
+ case "file":
49
+ case "image":
50
+ parts.push(toUIFilePart(contentPart));
51
+ break;
52
+ }
53
+ });
49
54
  uiMessages.push({
50
55
  ...common,
51
56
  role: "user",
@@ -95,11 +100,12 @@ export function toUIMessages(
95
100
  source,
96
101
  });
97
102
  }
98
- for (const file of message.files ?? []) {
99
- assistantMessage.parts.push(toUIFilePart(file));
100
- }
101
103
  for (const contentPart of nonStringContent) {
102
104
  switch (contentPart.type) {
105
+ case "file":
106
+ case "image":
107
+ assistantMessage.parts.push(toUIFilePart(contentPart));
108
+ break;
103
109
  case "tool-call":
104
110
  assistantMessage.parts.push({
105
111
  type: "step-start",