@tuturuuu/ai 0.8.0 → 0.8.2
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ai",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -22,34 +22,34 @@
|
|
|
22
22
|
"type-check": "tsc --project tsconfig.typecheck.json"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@ai-sdk/amazon-bedrock": "^5.0.
|
|
26
|
-
"@ai-sdk/anthropic": "^4.0.
|
|
27
|
-
"@ai-sdk/azure": "^4.0.
|
|
28
|
-
"@ai-sdk/cerebras": "^3.0.
|
|
29
|
-
"@ai-sdk/cohere": "^4.0.
|
|
30
|
-
"@ai-sdk/deepgram": "^3.0.
|
|
31
|
-
"@ai-sdk/deepinfra": "^3.0.
|
|
32
|
-
"@ai-sdk/deepseek": "^3.0.
|
|
33
|
-
"@ai-sdk/elevenlabs": "^3.0.
|
|
34
|
-
"@ai-sdk/fal": "^3.0.
|
|
35
|
-
"@ai-sdk/fireworks": "^3.0.
|
|
36
|
-
"@ai-sdk/gateway": "^4.0.
|
|
37
|
-
"@ai-sdk/gladia": "^3.0.
|
|
38
|
-
"@ai-sdk/google": "^4.0.
|
|
39
|
-
"@ai-sdk/google-vertex": "^5.0.
|
|
40
|
-
"@ai-sdk/groq": "^4.0.
|
|
41
|
-
"@ai-sdk/hume": "^3.0.
|
|
42
|
-
"@ai-sdk/lmnt": "^3.0.
|
|
43
|
-
"@ai-sdk/luma": "^3.0.
|
|
44
|
-
"@ai-sdk/mistral": "^4.0.
|
|
45
|
-
"@ai-sdk/openai": "^4.0.
|
|
46
|
-
"@ai-sdk/openai-compatible": "^3.0.
|
|
47
|
-
"@ai-sdk/perplexity": "^4.0.
|
|
48
|
-
"@ai-sdk/react": "^4.0.
|
|
49
|
-
"@ai-sdk/replicate": "^3.0.
|
|
50
|
-
"@ai-sdk/revai": "^3.0.
|
|
51
|
-
"@ai-sdk/togetherai": "^3.0.
|
|
52
|
-
"@ai-sdk/xai": "^4.0.
|
|
25
|
+
"@ai-sdk/amazon-bedrock": "^5.0.48",
|
|
26
|
+
"@ai-sdk/anthropic": "^4.0.34",
|
|
27
|
+
"@ai-sdk/azure": "^4.0.35",
|
|
28
|
+
"@ai-sdk/cerebras": "^3.0.25",
|
|
29
|
+
"@ai-sdk/cohere": "^4.0.23",
|
|
30
|
+
"@ai-sdk/deepgram": "^3.0.23",
|
|
31
|
+
"@ai-sdk/deepinfra": "^3.0.25",
|
|
32
|
+
"@ai-sdk/deepseek": "^3.0.24",
|
|
33
|
+
"@ai-sdk/elevenlabs": "^3.0.24",
|
|
34
|
+
"@ai-sdk/fal": "^3.0.24",
|
|
35
|
+
"@ai-sdk/fireworks": "^3.0.27",
|
|
36
|
+
"@ai-sdk/gateway": "^4.0.44",
|
|
37
|
+
"@ai-sdk/gladia": "^3.0.23",
|
|
38
|
+
"@ai-sdk/google": "^4.0.37",
|
|
39
|
+
"@ai-sdk/google-vertex": "^5.0.45",
|
|
40
|
+
"@ai-sdk/groq": "^4.0.24",
|
|
41
|
+
"@ai-sdk/hume": "^3.0.23",
|
|
42
|
+
"@ai-sdk/lmnt": "^3.0.23",
|
|
43
|
+
"@ai-sdk/luma": "^3.0.24",
|
|
44
|
+
"@ai-sdk/mistral": "^4.0.25",
|
|
45
|
+
"@ai-sdk/openai": "^4.0.34",
|
|
46
|
+
"@ai-sdk/openai-compatible": "^3.0.25",
|
|
47
|
+
"@ai-sdk/perplexity": "^4.0.25",
|
|
48
|
+
"@ai-sdk/react": "^4.0.59",
|
|
49
|
+
"@ai-sdk/replicate": "^3.0.24",
|
|
50
|
+
"@ai-sdk/revai": "^3.0.23",
|
|
51
|
+
"@ai-sdk/togetherai": "^3.0.26",
|
|
52
|
+
"@ai-sdk/xai": "^4.0.31",
|
|
53
53
|
"@beeper/chat-adapter-matrix": "^0.2.0",
|
|
54
54
|
"@bitbasti/chat-adapter-webex": "^0.1.0",
|
|
55
55
|
"@chat-adapter/discord": "^4.36.0",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@chat-adapter/whatsapp": "^4.36.0",
|
|
69
69
|
"@json-render/core": "^0.19.0",
|
|
70
70
|
"@json-render/react": "^0.19.0",
|
|
71
|
-
"@liveblocks/chat-sdk-adapter": "^3.23.
|
|
71
|
+
"@liveblocks/chat-sdk-adapter": "^3.23.1",
|
|
72
72
|
"@octokit/rest": "^22.0.1",
|
|
73
73
|
"@resend/chat-sdk-adapter": "^0.2.2",
|
|
74
74
|
"@streamdown/cjk": "^1.0.3",
|
|
@@ -76,12 +76,12 @@
|
|
|
76
76
|
"@streamdown/math": "^1.0.2",
|
|
77
77
|
"@streamdown/mermaid": "^1.0.2",
|
|
78
78
|
"@tuturuuu/google": "0.1.0",
|
|
79
|
-
"@tuturuuu/internal-api": "0.
|
|
79
|
+
"@tuturuuu/internal-api": "0.29.1",
|
|
80
80
|
"@tuturuuu/supabase": "0.5.0",
|
|
81
|
-
"@tuturuuu/utils": "0.
|
|
81
|
+
"@tuturuuu/utils": "0.24.2",
|
|
82
82
|
"@vercel/sandbox": "^2.9.2",
|
|
83
83
|
"@zernio/chat-sdk-adapter": "^0.5.0",
|
|
84
|
-
"ai": "^7.0.
|
|
84
|
+
"ai": "^7.0.56",
|
|
85
85
|
"bash-tool": "^1.3.18",
|
|
86
86
|
"chat": "^4.36.0",
|
|
87
87
|
"chat-adapter-baileys": "^2.1.0",
|
|
@@ -8,12 +8,119 @@ type ChatFile = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
const FILE_DOWNLOAD_CONCURRENCY = 4;
|
|
11
|
+
// Supabase storage `list` defaults to 100 rows, so a chat that accumulated more
|
|
12
|
+
// resources than this silently lost everything past the first page.
|
|
13
|
+
const STORAGE_LIST_PAGE_SIZE = 100;
|
|
14
|
+
const STORAGE_LIST_MAX_PAGES = 20;
|
|
15
|
+
// Every request re-sends the chat's whole resource set, so an unbounded chat
|
|
16
|
+
// would eventually build a request no provider will accept. Attachments are
|
|
17
|
+
// admitted oldest-first until the budget is spent.
|
|
18
|
+
const MAX_INJECTED_ATTACHMENT_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
// Storage only records a media type when the uploader knew one. Without this,
|
|
21
|
+
// an image whose content type was lost on the way in reads as
|
|
22
|
+
// `application/octet-stream`, misses the `image/` check, and reaches the model
|
|
23
|
+
// as "this format cannot be passed directly" — invisible, with no error.
|
|
24
|
+
const MEDIA_TYPE_BY_EXTENSION: Record<string, string> = {
|
|
25
|
+
aac: 'audio/aac',
|
|
26
|
+
avif: 'image/avif',
|
|
27
|
+
csv: 'text/csv',
|
|
28
|
+
flac: 'audio/flac',
|
|
29
|
+
gif: 'image/gif',
|
|
30
|
+
heic: 'image/heic',
|
|
31
|
+
jpeg: 'image/jpeg',
|
|
32
|
+
jpg: 'image/jpeg',
|
|
33
|
+
json: 'application/json',
|
|
34
|
+
m4a: 'audio/mp4',
|
|
35
|
+
md: 'text/markdown',
|
|
36
|
+
mov: 'video/quicktime',
|
|
37
|
+
mp3: 'audio/mpeg',
|
|
38
|
+
mp4: 'video/mp4',
|
|
39
|
+
ogg: 'audio/ogg',
|
|
40
|
+
opus: 'audio/ogg',
|
|
41
|
+
pdf: 'application/pdf',
|
|
42
|
+
png: 'image/png',
|
|
43
|
+
txt: 'text/plain',
|
|
44
|
+
wav: 'audio/wav',
|
|
45
|
+
webm: 'video/webm',
|
|
46
|
+
webp: 'image/webp',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const UNKNOWN_MEDIA_TYPE = 'application/octet-stream';
|
|
50
|
+
|
|
51
|
+
export function resolveAttachmentMediaType(
|
|
52
|
+
fileName: string,
|
|
53
|
+
metadataMediaType?: string | null
|
|
54
|
+
): string {
|
|
55
|
+
if (metadataMediaType && metadataMediaType !== UNKNOWN_MEDIA_TYPE) {
|
|
56
|
+
return metadataMediaType;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const extension = fileName.split('.').pop()?.toLowerCase();
|
|
60
|
+
if (extension && MEDIA_TYPE_BY_EXTENSION[extension]) {
|
|
61
|
+
return MEDIA_TYPE_BY_EXTENSION[extension];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return metadataMediaType || UNKNOWN_MEDIA_TYPE;
|
|
65
|
+
}
|
|
11
66
|
|
|
12
67
|
function maskIdentifier(value: string): string {
|
|
13
68
|
if (value.length <= 8) return value;
|
|
14
69
|
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
15
70
|
}
|
|
16
71
|
|
|
72
|
+
type StorageListClient = {
|
|
73
|
+
storage: {
|
|
74
|
+
from: (bucket: string) => {
|
|
75
|
+
list: (
|
|
76
|
+
path: string,
|
|
77
|
+
options: Record<string, unknown>
|
|
78
|
+
) => Promise<{
|
|
79
|
+
data:
|
|
80
|
+
| { name: string; metadata?: Record<string, unknown> | null }[]
|
|
81
|
+
| null;
|
|
82
|
+
error: unknown;
|
|
83
|
+
}>;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export async function listAllChatResourceFiles(
|
|
89
|
+
sbAdmin: StorageListClient,
|
|
90
|
+
storagePath: string
|
|
91
|
+
) {
|
|
92
|
+
const files: { name: string; metadata?: Record<string, unknown> | null }[] =
|
|
93
|
+
[];
|
|
94
|
+
|
|
95
|
+
for (let page = 0; page < STORAGE_LIST_MAX_PAGES; page += 1) {
|
|
96
|
+
const { data, error } = await sbAdmin.storage
|
|
97
|
+
.from('workspaces')
|
|
98
|
+
.list(storagePath, {
|
|
99
|
+
limit: STORAGE_LIST_PAGE_SIZE,
|
|
100
|
+
offset: page * STORAGE_LIST_PAGE_SIZE,
|
|
101
|
+
sortBy: { column: 'created_at', order: 'asc' },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (error) {
|
|
105
|
+
console.error('Error listing files:', error);
|
|
106
|
+
// Keep whatever paged in rather than dropping the whole set.
|
|
107
|
+
return { files, truncated: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!data || data.length === 0) break;
|
|
111
|
+
|
|
112
|
+
files.push(...data);
|
|
113
|
+
if (data.length < STORAGE_LIST_PAGE_SIZE) {
|
|
114
|
+
return { files, truncated: false };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
files,
|
|
120
|
+
truncated: files.length >= STORAGE_LIST_PAGE_SIZE * STORAGE_LIST_MAX_PAGES,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
17
124
|
async function getAllChatFiles(
|
|
18
125
|
wsId: string,
|
|
19
126
|
chatId: string,
|
|
@@ -23,24 +130,19 @@ async function getAllChatFiles(
|
|
|
23
130
|
const sbAdmin = await createAdminClient();
|
|
24
131
|
|
|
25
132
|
const storagePath = `${wsId}/chats/ai/resources/${chatId}`;
|
|
26
|
-
const {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
if (listError) {
|
|
33
|
-
console.error('Error listing files:', listError);
|
|
34
|
-
return [];
|
|
35
|
-
}
|
|
133
|
+
const { files, truncated } = await listAllChatResourceFiles(
|
|
134
|
+
sbAdmin,
|
|
135
|
+
storagePath
|
|
136
|
+
);
|
|
36
137
|
|
|
37
138
|
console.info('[Google Chat Files] listed chat files', {
|
|
38
139
|
wsId: maskIdentifier(wsId),
|
|
39
140
|
chatId: maskIdentifier(chatId),
|
|
40
|
-
fileCount: files
|
|
141
|
+
fileCount: files.length,
|
|
142
|
+
truncated,
|
|
41
143
|
});
|
|
42
144
|
|
|
43
|
-
if (
|
|
145
|
+
if (files.length === 0) {
|
|
44
146
|
return [];
|
|
45
147
|
}
|
|
46
148
|
|
|
@@ -57,10 +159,11 @@ async function getAllChatFiles(
|
|
|
57
159
|
|
|
58
160
|
const file = files[currentIndex]!;
|
|
59
161
|
const fileName = file.name || 'unknown';
|
|
60
|
-
const mediaType =
|
|
61
|
-
|
|
62
|
-
file.metadata?.
|
|
63
|
-
|
|
162
|
+
const mediaType = resolveAttachmentMediaType(
|
|
163
|
+
fileName,
|
|
164
|
+
(file.metadata?.mediaType as string | undefined) ||
|
|
165
|
+
(file.metadata?.mimetype as string | undefined)
|
|
166
|
+
);
|
|
64
167
|
|
|
65
168
|
const { data: fileData, error: downloadError } = await sbAdmin.storage
|
|
66
169
|
.from('workspaces')
|
|
@@ -116,6 +219,14 @@ function addFilesToContent(
|
|
|
116
219
|
'text/csv',
|
|
117
220
|
'application/json',
|
|
118
221
|
'text/markdown',
|
|
222
|
+
// Gemini accepts audio natively; without these an attached recording
|
|
223
|
+
// reached the model only as a "cannot be passed directly" note.
|
|
224
|
+
'audio/aac',
|
|
225
|
+
'audio/flac',
|
|
226
|
+
'audio/mp4',
|
|
227
|
+
'audio/mpeg',
|
|
228
|
+
'audio/ogg',
|
|
229
|
+
'audio/wav',
|
|
119
230
|
]);
|
|
120
231
|
|
|
121
232
|
if (typeof existingContent === 'string') {
|
|
@@ -132,8 +243,21 @@ function addFilesToContent(
|
|
|
132
243
|
}
|
|
133
244
|
}
|
|
134
245
|
|
|
246
|
+
let injectedBytes = 0;
|
|
247
|
+
let skippedForBudget = 0;
|
|
248
|
+
|
|
135
249
|
for (const file of chatFiles) {
|
|
136
250
|
const { content, mediaType, fileName } = file;
|
|
251
|
+
const byteLength =
|
|
252
|
+
content instanceof ArrayBuffer ? content.byteLength : content.length;
|
|
253
|
+
|
|
254
|
+
// Oldest-first: an early attachment the conversation is actually about
|
|
255
|
+
// should not be evicted by a later one.
|
|
256
|
+
if (injectedBytes + byteLength > MAX_INJECTED_ATTACHMENT_BYTES) {
|
|
257
|
+
skippedForBudget += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
injectedBytes += byteLength;
|
|
137
261
|
|
|
138
262
|
if (mediaType.startsWith('image/')) {
|
|
139
263
|
const imagePart: ImagePart = {
|
|
@@ -172,6 +296,14 @@ function addFilesToContent(
|
|
|
172
296
|
}
|
|
173
297
|
}
|
|
174
298
|
|
|
299
|
+
if (skippedForBudget > 0) {
|
|
300
|
+
// Say so rather than answering as though nothing was attached.
|
|
301
|
+
contentParts.push({
|
|
302
|
+
type: 'text',
|
|
303
|
+
text: `${skippedForBudget} older attachment(s) were omitted from this request because the conversation exceeds the attachment size budget. Ask the user to re-attach anything you need.`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
175
307
|
return contentParts;
|
|
176
308
|
}
|
|
177
309
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const MAX_INLINE_TRANSCRIPTION_AUDIO_BYTES = 18 * 1024 * 1024;
|
|
2
|
+
export const TRANSCRIPTION_MULTIPART_HEADROOM_BYTES = 1024 * 1024;
|
|
3
|
+
export const MAX_TRANSCRIPTION_MULTIPART_REQUEST_BYTES =
|
|
4
|
+
MAX_INLINE_TRANSCRIPTION_AUDIO_BYTES + TRANSCRIPTION_MULTIPART_HEADROOM_BYTES;
|
|
5
|
+
|
|
6
|
+
const TRANSCRIPTION_AUDIO_MEDIA_TYPES = [
|
|
7
|
+
'audio/webm',
|
|
8
|
+
'audio/mp4',
|
|
9
|
+
'audio/ogg',
|
|
10
|
+
'audio/mpeg',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type TranscriptionAudioMediaType =
|
|
14
|
+
(typeof TRANSCRIPTION_AUDIO_MEDIA_TYPES)[number];
|
|
15
|
+
|
|
16
|
+
type TranscriptionAudioValidation =
|
|
17
|
+
| { mediaType: TranscriptionAudioMediaType; ok: true }
|
|
18
|
+
| { message: string; ok: false; status: 400 | 413 | 415 };
|
|
19
|
+
|
|
20
|
+
export function validateTranscriptionAudioFile(
|
|
21
|
+
audio: Pick<File, 'size' | 'type'>
|
|
22
|
+
): TranscriptionAudioValidation {
|
|
23
|
+
if (audio.size <= 0) {
|
|
24
|
+
return { message: 'The recording is empty.', ok: false, status: 400 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (audio.size > MAX_INLINE_TRANSCRIPTION_AUDIO_BYTES) {
|
|
28
|
+
return {
|
|
29
|
+
message: 'The recording exceeds the inline transcription limit.',
|
|
30
|
+
ok: false,
|
|
31
|
+
status: 413,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const baseMediaType = audio.type.split(';', 1)[0]?.trim().toLowerCase();
|
|
36
|
+
const mediaType = TRANSCRIPTION_AUDIO_MEDIA_TYPES.find(
|
|
37
|
+
(allowedMediaType) => allowedMediaType === baseMediaType
|
|
38
|
+
);
|
|
39
|
+
if (!mediaType) {
|
|
40
|
+
return {
|
|
41
|
+
message:
|
|
42
|
+
'The recording format is not supported for inline transcription.',
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 415,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { mediaType, ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hasOversizedDeclaredContentLength(req: Request) {
|
|
52
|
+
const contentLength = req.headers.get('content-length')?.trim();
|
|
53
|
+
if (!contentLength || !/^\d+$/u.test(contentLength)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
BigInt(contentLength) > BigInt(MAX_TRANSCRIPTION_MULTIPART_REQUEST_BYTES)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -3,6 +3,10 @@ import { createClient } from '@tuturuuu/supabase/next/server';
|
|
|
3
3
|
import { generateObject } from 'ai';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { withAiMemory } from '../../memory';
|
|
6
|
+
import {
|
|
7
|
+
hasOversizedDeclaredContentLength,
|
|
8
|
+
validateTranscriptionAudioFile,
|
|
9
|
+
} from './input';
|
|
6
10
|
|
|
7
11
|
const DEFAULT_MODEL_NAME = 'gemini-3.1-flash-lite';
|
|
8
12
|
|
|
@@ -42,14 +46,23 @@ export function createPOST() {
|
|
|
42
46
|
return new Response('Unauthorized', { status: 401 });
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
if (hasOversizedDeclaredContentLength(req)) {
|
|
50
|
+
return new Response('Audio file too large', { status: 413 });
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const formData = await req.formData();
|
|
46
|
-
const audioFile = formData.get('audio')
|
|
54
|
+
const audioFile = formData.get('audio');
|
|
47
55
|
const wsId = formData.get('wsId') as string | null;
|
|
48
56
|
|
|
49
|
-
if (!audioFile) {
|
|
57
|
+
if (!(audioFile instanceof File)) {
|
|
50
58
|
return new Response('No audio file provided', { status: 400 });
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
const validation = validateTranscriptionAudioFile(audioFile);
|
|
62
|
+
if (!validation.ok) {
|
|
63
|
+
return new Response(validation.message, { status: validation.status });
|
|
64
|
+
}
|
|
65
|
+
|
|
53
66
|
// Convert file to buffer
|
|
54
67
|
const audioBuffer = await audioFile.arrayBuffer();
|
|
55
68
|
const audioUint8Array = new Uint8Array(audioBuffer);
|
|
@@ -73,7 +86,7 @@ export function createPOST() {
|
|
|
73
86
|
content: [
|
|
74
87
|
{
|
|
75
88
|
type: 'file',
|
|
76
|
-
mediaType:
|
|
89
|
+
mediaType: validation.mediaType,
|
|
77
90
|
data: audioUint8Array,
|
|
78
91
|
},
|
|
79
92
|
],
|