@tuturuuu/ai 0.0.10
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 +76 -0
- package/package.json +106 -0
- package/src/api-key-hash.ts +28 -0
- package/src/calendar/events.ts +34 -0
- package/src/calendar/route.ts +114 -0
- package/src/chat/credit-source.ts +1 -0
- package/src/chat/google/chat-request-schema.ts +150 -0
- package/src/chat/google/default-system-instruction.ts +198 -0
- package/src/chat/google/message-file-processing.ts +212 -0
- package/src/chat/google/mira-step-preparation.ts +221 -0
- package/src/chat/google/new/route.ts +368 -0
- package/src/chat/google/route-auth.ts +81 -0
- package/src/chat/google/route-chat-resolution.ts +98 -0
- package/src/chat/google/route-credits.ts +61 -0
- package/src/chat/google/route-message-preparation.ts +331 -0
- package/src/chat/google/route-mira-runtime.ts +206 -0
- package/src/chat/google/route.ts +632 -0
- package/src/chat/google/stream-finish-persistence.ts +722 -0
- package/src/chat/google/summary/route.ts +153 -0
- package/src/chat/mira-render-ui-policy.ts +540 -0
- package/src/chat/mira-system-instruction.ts +484 -0
- package/src/chat-sdk/adapters.ts +389 -0
- package/src/chat-sdk/registry.ts +197 -0
- package/src/chat-sdk.ts +33 -0
- package/src/core.ts +3 -0
- package/src/credits/cap-output-tokens.ts +90 -0
- package/src/credits/check-credits.ts +232 -0
- package/src/credits/constants.ts +30 -0
- package/src/credits/index.ts +46 -0
- package/src/credits/model-mapping.ts +92 -0
- package/src/credits/reservations.ts +514 -0
- package/src/credits/resolve-plan-model.ts +219 -0
- package/src/credits/sync-gateway-models.ts +351 -0
- package/src/credits/types.ts +109 -0
- package/src/credits/use-ai-credits.ts +3 -0
- package/src/embeddings/metered.ts +283 -0
- package/src/executions/route.ts +137 -0
- package/src/generate/route.ts +411 -0
- package/src/hooks.ts +7 -0
- package/src/meetings/summary/route.ts +7 -0
- package/src/meetings/transcription/route.ts +134 -0
- package/src/memory/client.ts +158 -0
- package/src/memory/config.ts +38 -0
- package/src/memory/index.ts +32 -0
- package/src/memory/ingest.ts +51 -0
- package/src/memory/middleware.ts +35 -0
- package/src/memory/operations.ts +480 -0
- package/src/memory/scope.ts +102 -0
- package/src/memory/settings.ts +121 -0
- package/src/memory/types.ts +101 -0
- package/src/memory/workspace.ts +36 -0
- package/src/memory.ts +1 -0
- package/src/mind/patch.ts +146 -0
- package/src/mind/route.ts +687 -0
- package/src/mind/tools.ts +1500 -0
- package/src/mind/types.ts +20 -0
- package/src/object/core.ts +3 -0
- package/src/object/flashcards/route.ts +140 -0
- package/src/object/quizzes/explanation/route.ts +145 -0
- package/src/object/quizzes/route.ts +142 -0
- package/src/object/types.ts +187 -0
- package/src/object/year-plan/route.ts +196 -0
- package/src/react.ts +1 -0
- package/src/scheduling/algorithm.ts +791 -0
- package/src/scheduling/default.ts +36 -0
- package/src/scheduling/duration-optimizer.ts +689 -0
- package/src/scheduling/index.ts +79 -0
- package/src/scheduling/priority-calculator.ts +187 -0
- package/src/scheduling/recurrence-calculator.ts +621 -0
- package/src/scheduling/templates.ts +892 -0
- package/src/scheduling/types.ts +136 -0
- package/src/scheduling/web-adapter.ts +308 -0
- package/src/scheduling.ts +6 -0
- package/src/supported-actions.ts +1 -0
- package/src/supported-providers.ts +6 -0
- package/src/tools/context-builder.ts +372 -0
- package/src/tools/core.ts +1 -0
- package/src/tools/definitions/calendar.ts +106 -0
- package/src/tools/definitions/finance.ts +197 -0
- package/src/tools/definitions/image.ts +74 -0
- package/src/tools/definitions/memory.ts +83 -0
- package/src/tools/definitions/meta.ts +154 -0
- package/src/tools/definitions/render-ui.ts +81 -0
- package/src/tools/definitions/tasks.ts +343 -0
- package/src/tools/definitions/time-tracking.ts +381 -0
- package/src/tools/definitions/workspace-context.ts +45 -0
- package/src/tools/definitions/workspace-user-chat.ts +111 -0
- package/src/tools/executors/calendar.ts +371 -0
- package/src/tools/executors/chat.ts +15 -0
- package/src/tools/executors/finance.ts +638 -0
- package/src/tools/executors/helpers/encryption.ts +107 -0
- package/src/tools/executors/image.ts +247 -0
- package/src/tools/executors/markitdown.ts +684 -0
- package/src/tools/executors/memory.ts +277 -0
- package/src/tools/executors/parallel-checks.ts +176 -0
- package/src/tools/executors/qr.ts +170 -0
- package/src/tools/executors/scope-helpers.ts +192 -0
- package/src/tools/executors/search.ts +149 -0
- package/src/tools/executors/settings.ts +40 -0
- package/src/tools/executors/tasks.ts +1087 -0
- package/src/tools/executors/theme.ts +23 -0
- package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
- package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
- package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
- package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
- package/src/tools/executors/timer/timer-helpers.ts +372 -0
- package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
- package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
- package/src/tools/executors/timer/timer-mutations.ts +19 -0
- package/src/tools/executors/timer/timer-queries.ts +18 -0
- package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
- package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
- package/src/tools/executors/timer/timer-session-queries.ts +153 -0
- package/src/tools/executors/timer/timer-session-updates.ts +200 -0
- package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
- package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
- package/src/tools/executors/timer.ts +22 -0
- package/src/tools/executors/user.ts +60 -0
- package/src/tools/executors/workspace.ts +135 -0
- package/src/tools/json-render-catalog.ts +875 -0
- package/src/tools/mira-tool-definitions.ts +55 -0
- package/src/tools/mira-tool-dispatcher.ts +265 -0
- package/src/tools/mira-tool-metadata.ts +164 -0
- package/src/tools/mira-tool-names.ts +95 -0
- package/src/tools/mira-tool-render-ui.ts +54 -0
- package/src/tools/mira-tool-types.ts +17 -0
- package/src/tools/mira-tools.ts +167 -0
- package/src/tools/normalize-render-ui-input.ts +321 -0
- package/src/tools/workspace-context.ts +233 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { google } from '@ai-sdk/google';
|
|
2
|
+
import { capMaxOutputTokensByCredits } from '@tuturuuu/ai/credits/cap-output-tokens';
|
|
3
|
+
import {
|
|
4
|
+
checkAiCredits,
|
|
5
|
+
deductAiCredits,
|
|
6
|
+
} from '@tuturuuu/ai/credits/check-credits';
|
|
7
|
+
import { toBareModelName } from '@tuturuuu/ai/credits/model-mapping';
|
|
8
|
+
import {
|
|
9
|
+
PlanModelResolutionError,
|
|
10
|
+
resolvePlanModel,
|
|
11
|
+
} from '@tuturuuu/ai/credits/resolve-plan-model';
|
|
12
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
13
|
+
import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
|
|
14
|
+
import { type FinishReason, streamText } from 'ai';
|
|
15
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
16
|
+
import { validateApiKeyHash } from '../api-key-hash';
|
|
17
|
+
import { withAiMemory } from '../memory';
|
|
18
|
+
|
|
19
|
+
const ALLOWED_MODELS = [
|
|
20
|
+
{
|
|
21
|
+
name: 'gemini-2.0-flash',
|
|
22
|
+
price: {
|
|
23
|
+
per1MInputTokens: 0.1,
|
|
24
|
+
per1MOutputTokens: 0.4,
|
|
25
|
+
per1MReasoningTokens: 0.4,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'gemini-2.0-flash-lite',
|
|
30
|
+
price: {
|
|
31
|
+
per1MInputTokens: 0.075,
|
|
32
|
+
per1MOutputTokens: 0.3,
|
|
33
|
+
per1MReasoningTokens: 0.3,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'gemini-2.5-flash',
|
|
38
|
+
price: {
|
|
39
|
+
per1MInputTokens: 0.3,
|
|
40
|
+
per1MOutputTokens: 2.5,
|
|
41
|
+
per1MReasoningTokens: 2.5,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'gemini-2.5-flash-lite',
|
|
46
|
+
price: {
|
|
47
|
+
per1MInputTokens: 0.1,
|
|
48
|
+
per1MOutputTokens: 0.4,
|
|
49
|
+
per1MReasoningTokens: 0.4,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'gemini-2.5-pro',
|
|
54
|
+
price: {
|
|
55
|
+
per1MInputTokens: 1.25,
|
|
56
|
+
per1MOutputTokens: 10,
|
|
57
|
+
per1MReasoningTokens: 10,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
] as const satisfies {
|
|
61
|
+
name: string;
|
|
62
|
+
price: {
|
|
63
|
+
per1MInputTokens: number;
|
|
64
|
+
per1MOutputTokens: number;
|
|
65
|
+
per1MReasoningTokens: number;
|
|
66
|
+
};
|
|
67
|
+
}[];
|
|
68
|
+
|
|
69
|
+
export function createPOST(
|
|
70
|
+
_options: { serverAPIKeyFallback?: boolean } = {
|
|
71
|
+
serverAPIKeyFallback: false,
|
|
72
|
+
}
|
|
73
|
+
) {
|
|
74
|
+
// Higher-order function that returns the actual request handler
|
|
75
|
+
return async function handler(req: NextRequest): Promise<Response> {
|
|
76
|
+
const sbAdmin = await createAdminClient();
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
prompt,
|
|
80
|
+
accessKey,
|
|
81
|
+
configs = {
|
|
82
|
+
wsId: ROOT_WORKSPACE_ID,
|
|
83
|
+
model: 'gemini-2.0-flash-lite',
|
|
84
|
+
systemPrompt: '',
|
|
85
|
+
thinkingBudget: 0,
|
|
86
|
+
includeThoughts: false,
|
|
87
|
+
},
|
|
88
|
+
} = (await req.json()) as {
|
|
89
|
+
prompt?: string;
|
|
90
|
+
dataType?: 'text' | 'file' | 'image';
|
|
91
|
+
mimeType?: string;
|
|
92
|
+
accessKey?: {
|
|
93
|
+
id: string;
|
|
94
|
+
value: string;
|
|
95
|
+
};
|
|
96
|
+
configs?: {
|
|
97
|
+
wsId: string;
|
|
98
|
+
model: (typeof ALLOWED_MODELS)[number]['name'];
|
|
99
|
+
systemPrompt?: string;
|
|
100
|
+
thinkingBudget?: number;
|
|
101
|
+
includeThoughts?: boolean;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (!configs?.wsId) {
|
|
106
|
+
configs.wsId = ROOT_WORKSPACE_ID;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!configs?.systemPrompt) {
|
|
110
|
+
configs.systemPrompt = '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!configs?.thinkingBudget) {
|
|
114
|
+
configs.thinkingBudget = 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!configs?.includeThoughts) {
|
|
118
|
+
configs.includeThoughts = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (!accessKey?.id || !accessKey?.value) {
|
|
123
|
+
console.error('Missing accessId or accessKey');
|
|
124
|
+
return new Response('Missing accessId or accessKey', { status: 400 });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!prompt) {
|
|
128
|
+
console.error('Missing prompt');
|
|
129
|
+
return new Response('Missing prompt', { status: 400 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let effectiveModel = configs.model;
|
|
133
|
+
try {
|
|
134
|
+
const resolvedModel = await resolvePlanModel({
|
|
135
|
+
capability: 'language',
|
|
136
|
+
requestedModel: configs.model,
|
|
137
|
+
wsId: configs.wsId,
|
|
138
|
+
});
|
|
139
|
+
effectiveModel = toBareModelName(
|
|
140
|
+
resolvedModel.modelId
|
|
141
|
+
) as (typeof ALLOWED_MODELS)[number]['name'];
|
|
142
|
+
configs.model = effectiveModel;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error instanceof PlanModelResolutionError) {
|
|
145
|
+
const status = error.code === 'NO_ALLOCATION' ? 503 : 500;
|
|
146
|
+
return NextResponse.json(
|
|
147
|
+
{ error: error.message, code: error.code },
|
|
148
|
+
{ status }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Pre-flight AI credit check (no userId available for API key auth)
|
|
156
|
+
const creditCheck = await checkAiCredits(
|
|
157
|
+
configs.wsId,
|
|
158
|
+
effectiveModel,
|
|
159
|
+
'generate'
|
|
160
|
+
);
|
|
161
|
+
if (!creditCheck.allowed) {
|
|
162
|
+
return NextResponse.json(
|
|
163
|
+
{
|
|
164
|
+
error: creditCheck.errorMessage || 'AI credits insufficient',
|
|
165
|
+
code: creditCheck.errorCode,
|
|
166
|
+
},
|
|
167
|
+
{ status: 403 }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!effectiveModel) {
|
|
172
|
+
console.error('Missing model');
|
|
173
|
+
return new Response('Missing model', { status: 400 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!ALLOWED_MODELS.some((model) => model.name === effectiveModel)) {
|
|
177
|
+
console.error('Invalid model');
|
|
178
|
+
return new Response(
|
|
179
|
+
`Invalid model: ${effectiveModel}\nAllowed models: ${ALLOWED_MODELS.map((model) => model.name).join(', ')}`,
|
|
180
|
+
{ status: 400 }
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!configs.wsId) {
|
|
185
|
+
console.error('Missing workspace ID');
|
|
186
|
+
return new Response('Missing workspace ID', { status: 400 });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { data: apiKeyData, error: apiKeyError } = await sbAdmin
|
|
190
|
+
.from('workspace_api_keys')
|
|
191
|
+
.select('id, created_by, scopes, key_hash')
|
|
192
|
+
.eq('ws_id', configs.wsId)
|
|
193
|
+
.eq('id', accessKey.id)
|
|
194
|
+
.single();
|
|
195
|
+
|
|
196
|
+
const isValidAccessKey =
|
|
197
|
+
!apiKeyError &&
|
|
198
|
+
!!apiKeyData?.key_hash &&
|
|
199
|
+
(await validateApiKeyHash(accessKey.value, apiKeyData.key_hash));
|
|
200
|
+
|
|
201
|
+
if (!isValidAccessKey || !apiKeyData) {
|
|
202
|
+
console.error('Invalid accessId or accessKey', apiKeyError);
|
|
203
|
+
return new Response('Invalid accessId or accessKey', { status: 400 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!apiKeyData.scopes.includes(effectiveModel)) {
|
|
207
|
+
console.error('Invalid model');
|
|
208
|
+
return new Response(
|
|
209
|
+
`Invalid model: ${effectiveModel}\nAllowed models: ${apiKeyData.scopes.join(', ')}`,
|
|
210
|
+
{ status: 400 }
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const apiKeyId = apiKeyData.id;
|
|
215
|
+
|
|
216
|
+
let result: {
|
|
217
|
+
input: string;
|
|
218
|
+
output: string;
|
|
219
|
+
usage: {
|
|
220
|
+
inputTokens: number;
|
|
221
|
+
outputTokens: number;
|
|
222
|
+
reasoningTokens: number;
|
|
223
|
+
totalTokens: number;
|
|
224
|
+
};
|
|
225
|
+
finishReason: FinishReason;
|
|
226
|
+
} | null = null;
|
|
227
|
+
|
|
228
|
+
// Apply credit-budget cap on maxOutputTokens (defense-in-depth)
|
|
229
|
+
const cappedMaxOutput = await capMaxOutputTokensByCredits(
|
|
230
|
+
sbAdmin,
|
|
231
|
+
effectiveModel,
|
|
232
|
+
creditCheck.maxOutputTokens,
|
|
233
|
+
creditCheck.remainingCredits
|
|
234
|
+
);
|
|
235
|
+
if (cappedMaxOutput === null && creditCheck.remainingCredits <= 0) {
|
|
236
|
+
return NextResponse.json(
|
|
237
|
+
{ error: 'AI credits insufficient', code: 'CREDITS_EXHAUSTED' },
|
|
238
|
+
{ status: 403 }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const stream = streamText({
|
|
243
|
+
model: await withAiMemory({
|
|
244
|
+
customId: `generate-${apiKeyId}-${Date.now()}`,
|
|
245
|
+
model: google(effectiveModel),
|
|
246
|
+
product: 'ai_chat',
|
|
247
|
+
source: 'api_generate',
|
|
248
|
+
surface: 'api_generate',
|
|
249
|
+
userId: apiKeyData.created_by,
|
|
250
|
+
wsId: configs.wsId,
|
|
251
|
+
}),
|
|
252
|
+
prompt,
|
|
253
|
+
system: configs.systemPrompt,
|
|
254
|
+
...(cappedMaxOutput ? { maxOutputTokens: cappedMaxOutput } : {}),
|
|
255
|
+
onFinish: async ({ text, finishReason, usage }) => {
|
|
256
|
+
result = {
|
|
257
|
+
input: prompt,
|
|
258
|
+
output: text,
|
|
259
|
+
usage: {
|
|
260
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
261
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
262
|
+
reasoningTokens: usage.reasoningTokens ?? 0,
|
|
263
|
+
totalTokens: usage.totalTokens ?? 0,
|
|
264
|
+
},
|
|
265
|
+
finishReason,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const insertData = {
|
|
269
|
+
ws_id: configs.wsId,
|
|
270
|
+
api_key_id: apiKeyId,
|
|
271
|
+
model_id: effectiveModel,
|
|
272
|
+
input: prompt,
|
|
273
|
+
output: text,
|
|
274
|
+
finish_reason: String(finishReason),
|
|
275
|
+
input_tokens: usage.inputTokens ?? 0,
|
|
276
|
+
output_tokens: usage.outputTokens ?? 0,
|
|
277
|
+
reasoning_tokens: usage.reasoningTokens ?? 0,
|
|
278
|
+
total_tokens: usage.totalTokens ?? 0,
|
|
279
|
+
system_prompt: configs.systemPrompt ?? '',
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const { data: execData, error: saveError } = await sbAdmin
|
|
283
|
+
.from('workspace_ai_executions')
|
|
284
|
+
.insert(insertData)
|
|
285
|
+
.select('id')
|
|
286
|
+
.single();
|
|
287
|
+
|
|
288
|
+
if (saveError) {
|
|
289
|
+
console.error('Error saving AI execution');
|
|
290
|
+
console.error(saveError);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Deduct AI credits (no userId for API key auth)
|
|
294
|
+
deductAiCredits({
|
|
295
|
+
wsId: configs.wsId,
|
|
296
|
+
modelId: effectiveModel,
|
|
297
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
298
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
299
|
+
reasoningTokens:
|
|
300
|
+
usage.outputTokenDetails?.reasoningTokens ??
|
|
301
|
+
usage.reasoningTokens ??
|
|
302
|
+
0,
|
|
303
|
+
feature: 'generate',
|
|
304
|
+
executionId: execData?.id,
|
|
305
|
+
}).catch((err) => console.error('Failed to deduct AI credits:', err));
|
|
306
|
+
},
|
|
307
|
+
providerOptions: {
|
|
308
|
+
google: {
|
|
309
|
+
responseModalities: ['TEXT'],
|
|
310
|
+
thinkingConfig: {
|
|
311
|
+
thinkingBudget: configs.thinkingBudget ?? 0,
|
|
312
|
+
includeThoughts: configs.includeThoughts ?? false,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
319
|
+
for await (const _ of stream.textStream) {
|
|
320
|
+
// console.log(textPart);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Calculate the cost of the request
|
|
324
|
+
const model = ALLOWED_MODELS.find(
|
|
325
|
+
(model) => model.name === effectiveModel
|
|
326
|
+
);
|
|
327
|
+
if (!model) {
|
|
328
|
+
console.error('Invalid model');
|
|
329
|
+
return new Response('Invalid model', { status: 400 });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (!result) {
|
|
333
|
+
console.error('No result');
|
|
334
|
+
return new Response('No result', { status: 400 });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const typedResult = result as {
|
|
338
|
+
input: string;
|
|
339
|
+
output: string;
|
|
340
|
+
usage: {
|
|
341
|
+
inputTokens: number;
|
|
342
|
+
outputTokens: number;
|
|
343
|
+
reasoningTokens: number;
|
|
344
|
+
totalTokens: number;
|
|
345
|
+
};
|
|
346
|
+
finishReason: FinishReason;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const cost = {
|
|
350
|
+
inputCost:
|
|
351
|
+
(typedResult.usage.inputTokens / 1_000_000) *
|
|
352
|
+
model.price.per1MInputTokens,
|
|
353
|
+
outputCost:
|
|
354
|
+
(typedResult.usage.outputTokens / 1_000_000) *
|
|
355
|
+
model.price.per1MOutputTokens,
|
|
356
|
+
reasoningCost:
|
|
357
|
+
(typedResult.usage.reasoningTokens / 1_000_000) *
|
|
358
|
+
model.price.per1MReasoningTokens,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const totalCostUSD =
|
|
362
|
+
cost.inputCost + cost.outputCost + cost.reasoningCost;
|
|
363
|
+
|
|
364
|
+
// Fetch dynamic USD→VND exchange rate via DB function, fallback to 26000
|
|
365
|
+
let exchangeRate = 26000;
|
|
366
|
+
try {
|
|
367
|
+
const { data: rate } = await sbAdmin.rpc('get_exchange_rate', {
|
|
368
|
+
p_from_currency: 'USD',
|
|
369
|
+
p_to_currency: 'VND',
|
|
370
|
+
});
|
|
371
|
+
if (rate && Number(rate) > 0) {
|
|
372
|
+
exchangeRate = Number(rate);
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// Use fallback rate
|
|
376
|
+
}
|
|
377
|
+
const totalCostVND = totalCostUSD * exchangeRate;
|
|
378
|
+
|
|
379
|
+
// Format VND cost: show up to 3 decimal places if under 1 VND, otherwise whole number
|
|
380
|
+
const formattedVNDCost =
|
|
381
|
+
totalCostVND < 1
|
|
382
|
+
? `${totalCostVND.toFixed(3)} VND`
|
|
383
|
+
: `${totalCostVND.toFixed(0)} VND`;
|
|
384
|
+
|
|
385
|
+
return NextResponse.json({
|
|
386
|
+
...typedResult,
|
|
387
|
+
cost: {
|
|
388
|
+
inputCost: `$${cost.inputCost.toFixed(8)}`,
|
|
389
|
+
outputCost: `$${cost.outputCost.toFixed(8)}`,
|
|
390
|
+
reasoningCost: `$${cost.reasoningCost.toFixed(8)}`,
|
|
391
|
+
totalCost: `$${totalCostUSD.toFixed(8)}`,
|
|
392
|
+
totalCostVND: formattedVNDCost,
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error instanceof Error) {
|
|
397
|
+
console.log(error.message);
|
|
398
|
+
return NextResponse.json(
|
|
399
|
+
{
|
|
400
|
+
message: `## Edge API Failure\nCould not complete the request. Please view the **Stack trace** below.\n\`\`\`bash\n${error instanceof Error ? error.stack : 'Unknown error'}`,
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
status: 500,
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
console.log(error);
|
|
408
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { google } from '@ai-sdk/google';
|
|
2
|
+
import { createClient } from '@tuturuuu/supabase/next/server';
|
|
3
|
+
import { generateObject } from 'ai';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { withAiMemory } from '../../memory';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MODEL_NAME = 'gemini-3.1-flash-lite';
|
|
8
|
+
|
|
9
|
+
const transcriptSchema = z.object({
|
|
10
|
+
text: z.string().describe('The full transcript text'),
|
|
11
|
+
segments: z
|
|
12
|
+
.array(
|
|
13
|
+
z.object({
|
|
14
|
+
text: z.string().describe('Text content of this segment'),
|
|
15
|
+
start: z.number().describe('Start time in seconds'),
|
|
16
|
+
end: z.number().describe('End time in seconds'),
|
|
17
|
+
})
|
|
18
|
+
)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Array of transcript segments with timestamps'),
|
|
21
|
+
language: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Detected language code (e.g. "en", "vi")'),
|
|
25
|
+
durationInSeconds: z
|
|
26
|
+
.number()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe('Total audio duration in seconds'),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function createPOST() {
|
|
32
|
+
return async function handler(req: Request) {
|
|
33
|
+
try {
|
|
34
|
+
const supabase = await createClient();
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
data: { user },
|
|
38
|
+
} = await supabase.auth.getUser();
|
|
39
|
+
|
|
40
|
+
if (!user) {
|
|
41
|
+
console.error('Unauthorized');
|
|
42
|
+
return new Response('Unauthorized', { status: 401 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const formData = await req.formData();
|
|
46
|
+
const audioFile = formData.get('audio') as File;
|
|
47
|
+
const wsId = formData.get('wsId') as string | null;
|
|
48
|
+
|
|
49
|
+
if (!audioFile) {
|
|
50
|
+
return new Response('No audio file provided', { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Convert file to buffer
|
|
54
|
+
const audioBuffer = await audioFile.arrayBuffer();
|
|
55
|
+
const audioUint8Array = new Uint8Array(audioBuffer);
|
|
56
|
+
|
|
57
|
+
const result = await generateObject({
|
|
58
|
+
model: await withAiMemory({
|
|
59
|
+
addMemory: 'never',
|
|
60
|
+
customId: `meeting-transcription-${Date.now()}`,
|
|
61
|
+
model: google(DEFAULT_MODEL_NAME),
|
|
62
|
+
product: 'meetings',
|
|
63
|
+
source: 'meeting_transcription',
|
|
64
|
+
surface: 'meeting_transcription',
|
|
65
|
+
userId: user.id,
|
|
66
|
+
wsId,
|
|
67
|
+
}),
|
|
68
|
+
schema: transcriptSchema,
|
|
69
|
+
system: systemInstruction,
|
|
70
|
+
messages: [
|
|
71
|
+
{
|
|
72
|
+
role: 'user',
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: 'file',
|
|
76
|
+
mediaType: 'audio/mpeg',
|
|
77
|
+
data: audioUint8Array,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
providerOptions: {
|
|
83
|
+
google: {
|
|
84
|
+
safetySettings: [
|
|
85
|
+
{
|
|
86
|
+
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
|
87
|
+
threshold: 'BLOCK_NONE',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
category: 'HARM_CATEGORY_HATE_SPEECH',
|
|
91
|
+
threshold: 'BLOCK_NONE',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
category: 'HARM_CATEGORY_HARASSMENT',
|
|
95
|
+
threshold: 'BLOCK_NONE',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
|
99
|
+
threshold: 'BLOCK_NONE',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return new Response(
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
text: result.object.text,
|
|
109
|
+
segments: result.object.segments,
|
|
110
|
+
language: result.object.language,
|
|
111
|
+
durationInSeconds: result.object.durationInSeconds,
|
|
112
|
+
}),
|
|
113
|
+
{
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error('Transcription error:', error);
|
|
119
|
+
return new Response('Internal server error', { status: 500 });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const systemInstruction = `
|
|
125
|
+
You are a helpful assistant that transcribes audio into structured text with timestamps.
|
|
126
|
+
|
|
127
|
+
Please analyze the audio and provide:
|
|
128
|
+
1. The complete transcript text
|
|
129
|
+
2. If possible, break it into segments with start and end timestamps (in seconds)
|
|
130
|
+
3. Detect the language used in the audio
|
|
131
|
+
4. Estimate the total duration of the audio
|
|
132
|
+
|
|
133
|
+
Return the result in the specified JSON schema format.
|
|
134
|
+
`;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { getAiMemoryConfig } from './config';
|
|
2
|
+
import type { AiMemoryConfig } from './types';
|
|
3
|
+
|
|
4
|
+
type RequestOptions = {
|
|
5
|
+
timeout?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type AddMemoryPayload = {
|
|
9
|
+
containerTag: string;
|
|
10
|
+
content: string;
|
|
11
|
+
customId: string;
|
|
12
|
+
embedding: number[];
|
|
13
|
+
metadata: Record<string, unknown>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type SearchMemoriesPayload = {
|
|
17
|
+
containerTag: string;
|
|
18
|
+
embedding: number[];
|
|
19
|
+
filters?: unknown;
|
|
20
|
+
include?: unknown;
|
|
21
|
+
limit: number;
|
|
22
|
+
q: string;
|
|
23
|
+
searchMode?: 'hybrid';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ListDocumentsPayload = {
|
|
27
|
+
containerTags: string[];
|
|
28
|
+
filters?: unknown;
|
|
29
|
+
includeContent: boolean;
|
|
30
|
+
limit: number;
|
|
31
|
+
order: 'asc' | 'desc';
|
|
32
|
+
sort: 'updatedAt';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ForgetMemoryPayload = {
|
|
36
|
+
containerTag: string;
|
|
37
|
+
id: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let cachedClient: AiMemoryServiceClient | null = null;
|
|
42
|
+
let cachedSignature: string | null = null;
|
|
43
|
+
|
|
44
|
+
function configSignature(config: AiMemoryConfig) {
|
|
45
|
+
return [config.apiKey, config.baseUrl ?? ''].join('|');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function trimTrailingSlash(value: string) {
|
|
49
|
+
return value.replace(/\/+$/u, '');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class AiMemoryServiceClient {
|
|
53
|
+
private readonly apiKey: string;
|
|
54
|
+
private readonly baseUrl: string;
|
|
55
|
+
private readonly timeoutMs: number;
|
|
56
|
+
|
|
57
|
+
constructor(config: AiMemoryConfig) {
|
|
58
|
+
this.apiKey = config.apiKey;
|
|
59
|
+
this.baseUrl = trimTrailingSlash(
|
|
60
|
+
config.baseUrl ?? 'http://supermemory:8787'
|
|
61
|
+
);
|
|
62
|
+
this.timeoutMs = config.timeoutMs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async add(payload: AddMemoryPayload, options?: RequestOptions) {
|
|
66
|
+
return this.post<{ id: string; status: string }>(
|
|
67
|
+
'/v1/memories',
|
|
68
|
+
payload,
|
|
69
|
+
options
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async searchMemories(
|
|
74
|
+
payload: SearchMemoriesPayload,
|
|
75
|
+
options?: RequestOptions
|
|
76
|
+
) {
|
|
77
|
+
return this.post<{
|
|
78
|
+
results: Array<{
|
|
79
|
+
chunk?: string;
|
|
80
|
+
id: string;
|
|
81
|
+
memory?: string;
|
|
82
|
+
metadata?: Record<string, unknown> | null;
|
|
83
|
+
similarity?: number;
|
|
84
|
+
updatedAt?: string;
|
|
85
|
+
}>;
|
|
86
|
+
}>('/v1/search', payload, options);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async listDocuments(payload: ListDocumentsPayload, options?: RequestOptions) {
|
|
90
|
+
return this.post<{
|
|
91
|
+
memories: Array<{
|
|
92
|
+
content?: string | null;
|
|
93
|
+
id: string;
|
|
94
|
+
metadata?: unknown;
|
|
95
|
+
status: string;
|
|
96
|
+
summary?: string | null;
|
|
97
|
+
title?: string | null;
|
|
98
|
+
updatedAt: string;
|
|
99
|
+
}>;
|
|
100
|
+
}>('/v1/documents/list', payload, options);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async forgetMemory(payload: ForgetMemoryPayload, options?: RequestOptions) {
|
|
104
|
+
return this.post<{ forgotten: boolean; id: string }>(
|
|
105
|
+
'/v1/memories/forget',
|
|
106
|
+
payload,
|
|
107
|
+
options
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async post<T>(
|
|
112
|
+
path: string,
|
|
113
|
+
payload: unknown,
|
|
114
|
+
options?: RequestOptions
|
|
115
|
+
): Promise<T> {
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timeout = setTimeout(
|
|
118
|
+
() => controller.abort(),
|
|
119
|
+
options?.timeout ?? this.timeoutMs
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
124
|
+
body: JSON.stringify(payload),
|
|
125
|
+
headers: {
|
|
126
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
127
|
+
'Content-Type': 'application/json',
|
|
128
|
+
},
|
|
129
|
+
method: 'POST',
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`AI memory request failed with HTTP ${response.status}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (await response.json()) as T;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getAiMemoryServiceClient(config = getAiMemoryConfig()) {
|
|
147
|
+
if (!config) return null;
|
|
148
|
+
|
|
149
|
+
const signature = configSignature(config);
|
|
150
|
+
if (!cachedClient || cachedSignature !== signature) {
|
|
151
|
+
cachedClient = new AiMemoryServiceClient(config);
|
|
152
|
+
cachedSignature = signature;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return cachedClient;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const getSupermemoryClient = getAiMemoryServiceClient;
|