@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.
- package/README.md +23 -12
- package/dist/commonjs/client/_generated/_ignore.d.ts +1 -0
- package/dist/commonjs/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/commonjs/client/_generated/_ignore.js +3 -0
- package/dist/commonjs/client/_generated/_ignore.js.map +1 -0
- package/dist/commonjs/client/files.d.ts +57 -0
- package/dist/commonjs/client/files.d.ts.map +1 -0
- package/dist/commonjs/client/files.js +124 -0
- package/dist/commonjs/client/files.js.map +1 -0
- package/dist/commonjs/client/index.d.ts +487 -318
- package/dist/commonjs/client/index.d.ts.map +1 -1
- package/dist/commonjs/client/index.js +652 -550
- package/dist/commonjs/client/index.js.map +1 -1
- package/dist/commonjs/client/types.d.ts +35 -5
- package/dist/commonjs/client/types.d.ts.map +1 -1
- package/dist/commonjs/component/_generated/api.d.ts +47 -711
- package/dist/commonjs/component/files.d.ts +66 -21
- package/dist/commonjs/component/files.d.ts.map +1 -1
- package/dist/commonjs/component/files.js +71 -26
- package/dist/commonjs/component/files.js.map +1 -1
- package/dist/commonjs/component/messages.d.ts +31 -499
- package/dist/commonjs/component/messages.d.ts.map +1 -1
- package/dist/commonjs/component/messages.js +45 -125
- package/dist/commonjs/component/messages.js.map +1 -1
- package/dist/commonjs/component/schema.d.ts +949 -3785
- package/dist/commonjs/component/schema.d.ts.map +1 -1
- package/dist/commonjs/component/schema.js +9 -25
- package/dist/commonjs/component/schema.js.map +1 -1
- package/dist/commonjs/component/streams.d.ts +3 -3
- package/dist/commonjs/mapping.d.ts +28 -19
- package/dist/commonjs/mapping.d.ts.map +1 -1
- package/dist/commonjs/mapping.js +136 -81
- package/dist/commonjs/mapping.js.map +1 -1
- package/dist/commonjs/react/toUIMessages.d.ts.map +1 -1
- package/dist/commonjs/react/toUIMessages.js +12 -6
- package/dist/commonjs/react/toUIMessages.js.map +1 -1
- package/dist/commonjs/react/usePaginatedQuery.d.ts.map +1 -1
- package/dist/commonjs/react/usePaginatedQuery.js +0 -19
- package/dist/commonjs/react/usePaginatedQuery.js.map +1 -1
- package/dist/commonjs/validators.d.ts +851 -3388
- package/dist/commonjs/validators.d.ts.map +1 -1
- package/dist/commonjs/validators.js +11 -37
- package/dist/commonjs/validators.js.map +1 -1
- package/dist/commonjs.tsbuildinfo +1 -1
- package/dist/esm/client/_generated/_ignore.d.ts +1 -0
- package/dist/esm/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/esm/client/_generated/_ignore.js +3 -0
- package/dist/esm/client/_generated/_ignore.js.map +1 -0
- package/dist/esm/client/files.d.ts +57 -0
- package/dist/esm/client/files.d.ts.map +1 -0
- package/dist/esm/client/files.js +124 -0
- package/dist/esm/client/files.js.map +1 -0
- package/dist/esm/client/index.d.ts +487 -318
- package/dist/esm/client/index.d.ts.map +1 -1
- package/dist/esm/client/index.js +652 -550
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/types.d.ts +35 -5
- package/dist/esm/client/types.d.ts.map +1 -1
- package/dist/esm/component/_generated/api.d.ts +47 -711
- package/dist/esm/component/files.d.ts +66 -21
- package/dist/esm/component/files.d.ts.map +1 -1
- package/dist/esm/component/files.js +71 -26
- package/dist/esm/component/files.js.map +1 -1
- package/dist/esm/component/messages.d.ts +31 -499
- package/dist/esm/component/messages.d.ts.map +1 -1
- package/dist/esm/component/messages.js +45 -125
- package/dist/esm/component/messages.js.map +1 -1
- package/dist/esm/component/schema.d.ts +949 -3785
- package/dist/esm/component/schema.d.ts.map +1 -1
- package/dist/esm/component/schema.js +9 -25
- package/dist/esm/component/schema.js.map +1 -1
- package/dist/esm/component/streams.d.ts +3 -3
- package/dist/esm/mapping.d.ts +28 -19
- package/dist/esm/mapping.d.ts.map +1 -1
- package/dist/esm/mapping.js +136 -81
- package/dist/esm/mapping.js.map +1 -1
- package/dist/esm/react/toUIMessages.d.ts.map +1 -1
- package/dist/esm/react/toUIMessages.js +12 -6
- package/dist/esm/react/toUIMessages.js.map +1 -1
- package/dist/esm/react/usePaginatedQuery.d.ts.map +1 -1
- package/dist/esm/react/usePaginatedQuery.js +0 -19
- package/dist/esm/react/usePaginatedQuery.js.map +1 -1
- package/dist/esm/validators.d.ts +851 -3388
- package/dist/esm/validators.d.ts.map +1 -1
- package/dist/esm/validators.js +11 -37
- package/dist/esm/validators.js.map +1 -1
- package/dist/esm.tsbuildinfo +1 -1
- package/package.json +11 -9
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/files.ts +167 -0
- package/src/client/index.test.ts +127 -1
- package/src/client/index.ts +970 -812
- package/src/client/setup.test.ts +29 -0
- package/src/client/types.ts +41 -3
- package/src/component/_generated/api.d.ts +47 -711
- package/src/component/files.test.ts +130 -0
- package/src/component/files.ts +83 -27
- package/src/component/messages.test.ts +2 -1
- package/src/component/messages.ts +47 -137
- package/src/component/schema.ts +8 -28
- package/src/mapping.test.ts +163 -0
- package/src/mapping.ts +173 -103
- package/src/react/deltas.test.ts +4 -1
- package/src/react/toUIMessages.ts +12 -6
- package/src/react/usePaginatedQuery.ts +0 -31
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
48
|
+
const { content, fileIds } = await serializeContent(
|
|
49
|
+
ctx,
|
|
50
|
+
component,
|
|
51
|
+
message.content
|
|
52
|
+
);
|
|
45
53
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
):
|
|
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
|
|
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(
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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(
|
|
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(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
|
package/src/react/deltas.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
47
|
-
|
|
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",
|