@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,684 @@
|
|
|
1
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
2
|
+
import {
|
|
3
|
+
commitFixedAiCreditReservation,
|
|
4
|
+
releaseFixedAiCreditReservation,
|
|
5
|
+
reserveFixedAiCredits,
|
|
6
|
+
} from '../../credits/reservations';
|
|
7
|
+
import type { MiraToolContext } from '../mira-tools';
|
|
8
|
+
|
|
9
|
+
const MARKITDOWN_COST_CREDITS = 100;
|
|
10
|
+
const CREDIT_FEATURE = 'chat' as const;
|
|
11
|
+
const MARKITDOWN_LEDGER_MODEL = 'markitdown/conversion';
|
|
12
|
+
const DEFAULT_MARKITDOWN_TIMEOUT_MS = 30_000;
|
|
13
|
+
const MIN_MARKITDOWN_TIMEOUT_MS = 1_000;
|
|
14
|
+
const YOUTUBE_HOSTS = new Set([
|
|
15
|
+
'youtube.com',
|
|
16
|
+
'www.youtube.com',
|
|
17
|
+
'm.youtube.com',
|
|
18
|
+
'music.youtube.com',
|
|
19
|
+
'youtube-nocookie.com',
|
|
20
|
+
'www.youtube-nocookie.com',
|
|
21
|
+
'youtu.be',
|
|
22
|
+
'www.youtu.be',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function stripTimestampPrefix(name: string): string {
|
|
26
|
+
const match = name.match(/^\d+_(.+)$/);
|
|
27
|
+
return match?.[1] ?? name;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isUnsafeStoragePath(value: string): boolean {
|
|
31
|
+
return value.includes('..') || value.includes('\\');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isLikelyBareFileName(value: string): boolean {
|
|
35
|
+
return Boolean(value) && !value.includes('/') && !value.includes(':');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseUserGroupStoragePath(value: string, wsId: string) {
|
|
39
|
+
const prefix = `${wsId}/user-groups/`;
|
|
40
|
+
if (!value.startsWith(prefix)) return null;
|
|
41
|
+
|
|
42
|
+
const remainingPath = value.slice(prefix.length);
|
|
43
|
+
const firstSlashIndex = remainingPath.indexOf('/');
|
|
44
|
+
if (firstSlashIndex <= 0 || firstSlashIndex === remainingPath.length - 1) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
groupId: remainingPath.slice(0, firstSlashIndex),
|
|
50
|
+
storagePath: value,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getStoragePathFileName(value: string): string {
|
|
55
|
+
return value.split('/').pop() ?? value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeYoutubeUrl(value: string): string | null {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(value);
|
|
61
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
62
|
+
if (parsed.protocol !== 'https:') return null;
|
|
63
|
+
if (!YOUTUBE_HOSTS.has(hostname)) return null;
|
|
64
|
+
|
|
65
|
+
if (hostname === 'youtu.be' || hostname === 'www.youtu.be') {
|
|
66
|
+
return parsed.pathname.length > 1 ? parsed.toString() : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const isKnownYoutubePath =
|
|
70
|
+
parsed.pathname === '/watch' ||
|
|
71
|
+
parsed.pathname.startsWith('/shorts/') ||
|
|
72
|
+
parsed.pathname.startsWith('/embed/') ||
|
|
73
|
+
parsed.pathname.startsWith('/live/');
|
|
74
|
+
|
|
75
|
+
if (!isKnownYoutubePath) return null;
|
|
76
|
+
if (parsed.pathname === '/watch' && !parsed.searchParams.get('v')) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return parsed.toString();
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseBaseUrl(value: string): string | null {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(value);
|
|
89
|
+
const isLocalhost =
|
|
90
|
+
parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
91
|
+
const isDockerInternalHost =
|
|
92
|
+
parsed.hostname === 'markitdown' ||
|
|
93
|
+
parsed.hostname === 'host.docker.internal';
|
|
94
|
+
|
|
95
|
+
// Require HTTPS, except for local and Docker-internal service calls.
|
|
96
|
+
if (
|
|
97
|
+
parsed.protocol !== 'https:' &&
|
|
98
|
+
!((isLocalhost || isDockerInternalHost) && parsed.protocol === 'http:')
|
|
99
|
+
) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
parsed.search = '';
|
|
104
|
+
parsed.hash = '';
|
|
105
|
+
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveConfiguredMarkitdownUrl(): string | null {
|
|
112
|
+
const endpointUrl = process.env.MARKITDOWN_ENDPOINT_URL?.trim();
|
|
113
|
+
if (!endpointUrl) return null;
|
|
114
|
+
|
|
115
|
+
const normalizedEndpointUrl = parseBaseUrl(endpointUrl);
|
|
116
|
+
if (!normalizedEndpointUrl) return null;
|
|
117
|
+
return normalizedEndpointUrl;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveDiscordMarkitdownUrl(): string | null {
|
|
121
|
+
const deploymentUrl = process.env.DISCORD_APP_DEPLOYMENT_URL?.trim();
|
|
122
|
+
if (!deploymentUrl) return null;
|
|
123
|
+
const normalizedBaseUrl = parseBaseUrl(deploymentUrl);
|
|
124
|
+
if (!normalizedBaseUrl) return null;
|
|
125
|
+
return `${normalizedBaseUrl}/markitdown`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveDiscordMarkitdownSecret(): string | null {
|
|
129
|
+
return (
|
|
130
|
+
process.env.MARKITDOWN_ENDPOINT_SECRET?.trim() ||
|
|
131
|
+
process.env.VERCEL_CRON_SECRET?.trim() ||
|
|
132
|
+
process.env.CRON_SECRET?.trim() ||
|
|
133
|
+
null
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveMarkitdownTimeoutMs(): number {
|
|
138
|
+
const rawTimeoutMs = process.env.MARKITDOWN_TIMEOUT_MS?.trim();
|
|
139
|
+
if (!rawTimeoutMs) {
|
|
140
|
+
return DEFAULT_MARKITDOWN_TIMEOUT_MS;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const parsedTimeoutMs = Number(rawTimeoutMs);
|
|
144
|
+
if (!Number.isFinite(parsedTimeoutMs) || parsedTimeoutMs <= 0) {
|
|
145
|
+
return DEFAULT_MARKITDOWN_TIMEOUT_MS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Math.max(MIN_MARKITDOWN_TIMEOUT_MS, Math.floor(parsedTimeoutMs));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function reserveMarkitdownCredits(
|
|
152
|
+
sbAdmin: { rpc: unknown },
|
|
153
|
+
billingWsId: string,
|
|
154
|
+
ctx: MiraToolContext,
|
|
155
|
+
metadata: Record<string, unknown>
|
|
156
|
+
): Promise<
|
|
157
|
+
| { ok: true; reservationId: string; remainingCredits: number }
|
|
158
|
+
| { ok: false; error: string }
|
|
159
|
+
> {
|
|
160
|
+
const result = await reserveFixedAiCredits(
|
|
161
|
+
{
|
|
162
|
+
wsId: billingWsId,
|
|
163
|
+
userId: ctx.userId,
|
|
164
|
+
amount: MARKITDOWN_COST_CREDITS,
|
|
165
|
+
modelId: MARKITDOWN_LEDGER_MODEL,
|
|
166
|
+
feature: CREDIT_FEATURE,
|
|
167
|
+
metadata: {
|
|
168
|
+
...metadata,
|
|
169
|
+
fixedCredits: MARKITDOWN_COST_CREDITS,
|
|
170
|
+
source: 'markitdown_tool',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
sbAdmin
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (!result.success || !result.reservationId) {
|
|
177
|
+
if (result.errorCode === 'INSUFFICIENT_CREDITS') {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: `Insufficient credits. This conversion needs ${MARKITDOWN_COST_CREDITS} credits.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, error: 'Failed to reserve AI credits.' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
reservationId: result.reservationId,
|
|
189
|
+
remainingCredits: result.remainingCredits,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function commitMarkitdownCredits(
|
|
194
|
+
sbAdmin: { rpc: unknown },
|
|
195
|
+
reservationId: string,
|
|
196
|
+
metadata: Record<string, unknown>
|
|
197
|
+
): Promise<
|
|
198
|
+
{ ok: true; remainingCredits: number } | { ok: false; error: string }
|
|
199
|
+
> {
|
|
200
|
+
const result = await commitFixedAiCreditReservation(
|
|
201
|
+
reservationId,
|
|
202
|
+
{
|
|
203
|
+
...metadata,
|
|
204
|
+
fixedCredits: MARKITDOWN_COST_CREDITS,
|
|
205
|
+
source: 'markitdown_tool',
|
|
206
|
+
},
|
|
207
|
+
sbAdmin
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (!result.success) {
|
|
211
|
+
if (result.errorCode === 'RESERVATION_EXPIRED') {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error:
|
|
215
|
+
'AI credit reservation expired before the conversion could be completed.',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ok: false, error: 'Failed to deduct AI credits.' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
remainingCredits: result.remainingCredits,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function releaseMarkitdownCredits(
|
|
228
|
+
sbAdmin: { rpc: unknown },
|
|
229
|
+
reservationId: string,
|
|
230
|
+
metadata: Record<string, unknown>
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
const result = await releaseFixedAiCreditReservation(
|
|
233
|
+
reservationId,
|
|
234
|
+
{
|
|
235
|
+
...metadata,
|
|
236
|
+
fixedCredits: MARKITDOWN_COST_CREDITS,
|
|
237
|
+
source: 'markitdown_tool',
|
|
238
|
+
},
|
|
239
|
+
sbAdmin
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (!result.success) {
|
|
243
|
+
console.error('MarkItDown: failed to release AI credit reservation:', {
|
|
244
|
+
reservationId,
|
|
245
|
+
errorCode: result.errorCode,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function executeConvertFileToMarkdown(
|
|
251
|
+
args: Record<string, unknown>,
|
|
252
|
+
ctx: MiraToolContext
|
|
253
|
+
) {
|
|
254
|
+
const billingWsId = ctx.creditWsId ?? ctx.wsId;
|
|
255
|
+
const markitdownUrl =
|
|
256
|
+
resolveConfiguredMarkitdownUrl() ?? resolveDiscordMarkitdownUrl();
|
|
257
|
+
const markitdownSecret = resolveDiscordMarkitdownSecret();
|
|
258
|
+
|
|
259
|
+
if (!markitdownUrl) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error:
|
|
263
|
+
'MarkItDown endpoint is not configured. Set MARKITDOWN_ENDPOINT_URL or DISCORD_APP_DEPLOYMENT_URL.',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!markitdownSecret) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error:
|
|
271
|
+
'MarkItDown endpoint secret is not configured. Set MARKITDOWN_ENDPOINT_SECRET or CRON secret.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const storagePathArg =
|
|
276
|
+
typeof args.storagePath === 'string' ? args.storagePath.trim() : '';
|
|
277
|
+
const sourceUrlArg = typeof args.url === 'string' ? args.url.trim() : '';
|
|
278
|
+
const fileNameArg =
|
|
279
|
+
typeof args.fileName === 'string' ? args.fileName.trim() : '';
|
|
280
|
+
const maxCharactersRaw =
|
|
281
|
+
typeof args.maxCharacters === 'number' &&
|
|
282
|
+
Number.isFinite(args.maxCharacters)
|
|
283
|
+
? Math.floor(args.maxCharacters)
|
|
284
|
+
: 120_000;
|
|
285
|
+
const maxCharacters = Math.min(Math.max(maxCharactersRaw, 10_000), 120_000);
|
|
286
|
+
|
|
287
|
+
const expectedPrefix = `${ctx.wsId}/chats/ai/resources/`;
|
|
288
|
+
const chatFolder = ctx.chatId
|
|
289
|
+
? `${ctx.wsId}/chats/ai/resources/${ctx.chatId}`
|
|
290
|
+
: '';
|
|
291
|
+
let targetPath = storagePathArg;
|
|
292
|
+
let sourceUrl = sourceUrlArg ? normalizeYoutubeUrl(sourceUrlArg) : null;
|
|
293
|
+
let selectedFileName = '';
|
|
294
|
+
|
|
295
|
+
if (sourceUrlArg && !sourceUrl) {
|
|
296
|
+
return {
|
|
297
|
+
ok: false,
|
|
298
|
+
error:
|
|
299
|
+
'Invalid URL for MarkItDown. Direct URL conversion currently supports HTTPS YouTube links only.',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!sourceUrl && targetPath) {
|
|
304
|
+
sourceUrl = normalizeYoutubeUrl(targetPath);
|
|
305
|
+
if (sourceUrl) {
|
|
306
|
+
targetPath = '';
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (sourceUrl) {
|
|
311
|
+
const markitdownTimeoutMs = resolveMarkitdownTimeoutMs();
|
|
312
|
+
const abortController = new AbortController();
|
|
313
|
+
const timeoutId = setTimeout(
|
|
314
|
+
() => abortController.abort(),
|
|
315
|
+
markitdownTimeoutMs
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
let metadataResponse: Response;
|
|
319
|
+
try {
|
|
320
|
+
metadataResponse = await fetch(markitdownUrl, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: {
|
|
323
|
+
Authorization: `Bearer ${markitdownSecret}`,
|
|
324
|
+
'Content-Type': 'application/json',
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify({
|
|
327
|
+
url: sourceUrl,
|
|
328
|
+
filename: fileNameArg || 'youtube.md',
|
|
329
|
+
enable_plugins: true,
|
|
330
|
+
}),
|
|
331
|
+
signal: abortController.signal,
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const message =
|
|
335
|
+
error instanceof Error && error.name === 'AbortError'
|
|
336
|
+
? `YouTube metadata lookup timed out after ${markitdownTimeoutMs}ms.`
|
|
337
|
+
: 'Failed to reach YouTube metadata service.';
|
|
338
|
+
console.error('YouTube metadata request failed:', error);
|
|
339
|
+
return { ok: false, error: message };
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(timeoutId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!metadataResponse.ok) {
|
|
345
|
+
const rawBody = await metadataResponse.text().catch(() => '');
|
|
346
|
+
const safeMessage = rawBody.replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
347
|
+
console.error('YouTube metadata lookup failed:', {
|
|
348
|
+
status: metadataResponse.status,
|
|
349
|
+
body: safeMessage,
|
|
350
|
+
});
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
error: `YouTube metadata lookup failed (status ${metadataResponse.status}).`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let payload: { title?: unknown };
|
|
358
|
+
try {
|
|
359
|
+
payload = (await metadataResponse.json()) as { title?: unknown };
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.error('YouTube metadata service returned invalid JSON:', error);
|
|
362
|
+
return { ok: false, error: 'YouTube metadata lookup failed.' };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const title =
|
|
366
|
+
typeof payload.title === 'string' && payload.title.trim()
|
|
367
|
+
? payload.title.trim()
|
|
368
|
+
: null;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
title,
|
|
373
|
+
fileName: fileNameArg || null,
|
|
374
|
+
storagePath: null,
|
|
375
|
+
url: sourceUrl,
|
|
376
|
+
metadataOnly: true,
|
|
377
|
+
supportedCapabilities: ['youtube_title_metadata'],
|
|
378
|
+
unsupportedCapabilities: ['youtube_transcription', 'youtube_summary'],
|
|
379
|
+
message: title
|
|
380
|
+
? `Only YouTube metadata is supported. Title: ${title}. YouTube transcripts and summaries are not supported.`
|
|
381
|
+
: 'Only YouTube metadata is supported. YouTube transcripts and summaries are not supported.',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!sourceUrl && targetPath) {
|
|
386
|
+
if (isUnsafeStoragePath(targetPath)) {
|
|
387
|
+
return { ok: false, error: 'Invalid storagePath for current workspace.' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const userGroupStoragePath = parseUserGroupStoragePath(
|
|
391
|
+
targetPath,
|
|
392
|
+
ctx.wsId
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
if (chatFolder && targetPath.startsWith(`${chatFolder}/`)) {
|
|
396
|
+
selectedFileName = getStoragePathFileName(targetPath);
|
|
397
|
+
} else if (userGroupStoragePath) {
|
|
398
|
+
let canReadUserGroupStorage = false;
|
|
399
|
+
try {
|
|
400
|
+
canReadUserGroupStorage = Boolean(
|
|
401
|
+
await ctx.canReadUserGroupStorage?.({
|
|
402
|
+
groupId: userGroupStoragePath.groupId,
|
|
403
|
+
storagePath: userGroupStoragePath.storagePath,
|
|
404
|
+
wsId: ctx.wsId,
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
} catch {
|
|
408
|
+
canReadUserGroupStorage = false;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!canReadUserGroupStorage) {
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
error: 'You do not have permission to read this user-group file.',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
selectedFileName = getStoragePathFileName(targetPath);
|
|
419
|
+
} else if (isLikelyBareFileName(targetPath)) {
|
|
420
|
+
selectedFileName = targetPath;
|
|
421
|
+
targetPath = '';
|
|
422
|
+
} else if (chatFolder && targetPath.startsWith(expectedPrefix)) {
|
|
423
|
+
// The client can still show pre-move temp attachment paths while the
|
|
424
|
+
// server has already moved the object into the chat folder. Resolve the
|
|
425
|
+
// basename against the current chat instead of failing on stale metadata.
|
|
426
|
+
selectedFileName = fileNameArg || getStoragePathFileName(targetPath);
|
|
427
|
+
targetPath = '';
|
|
428
|
+
} else {
|
|
429
|
+
return { ok: false, error: 'Invalid storagePath for current workspace.' };
|
|
430
|
+
}
|
|
431
|
+
} else if (!sourceUrl) {
|
|
432
|
+
selectedFileName = fileNameArg;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const sbAdmin = await createAdminClient();
|
|
436
|
+
|
|
437
|
+
if (!sourceUrl && !targetPath) {
|
|
438
|
+
if (!ctx.chatId) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
error:
|
|
442
|
+
'No file specified and chat context is missing. Provide `storagePath`.',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const { data: listedFiles, error: listError } = await sbAdmin.storage
|
|
447
|
+
.from('workspaces')
|
|
448
|
+
.list(chatFolder, {
|
|
449
|
+
limit: 100,
|
|
450
|
+
sortBy: { column: 'created_at', order: 'desc' },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (listError) {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
error: `Failed to list chat files: ${listError.message}`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const realFiles = (listedFiles ?? []).filter(
|
|
461
|
+
(entry) => entry.id != null && entry.name !== '.emptyFolderPlaceholder'
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
if (realFiles.length === 0) {
|
|
465
|
+
return { ok: false, error: 'No files found in this chat.' };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const pickedFile = selectedFileName
|
|
469
|
+
? realFiles.find(
|
|
470
|
+
(entry) =>
|
|
471
|
+
entry.name.toLowerCase() === selectedFileName.toLowerCase() ||
|
|
472
|
+
stripTimestampPrefix(entry.name).toLowerCase() ===
|
|
473
|
+
selectedFileName.toLowerCase()
|
|
474
|
+
)
|
|
475
|
+
: realFiles[0];
|
|
476
|
+
|
|
477
|
+
if (!pickedFile) {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
error: `File "${selectedFileName}" was not found in this chat.`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
selectedFileName = pickedFile.name;
|
|
485
|
+
targetPath = `${chatFolder}/${pickedFile.name}`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const sourceLabel = sourceUrl
|
|
489
|
+
? sourceUrl
|
|
490
|
+
: stripTimestampPrefix(selectedFileName);
|
|
491
|
+
|
|
492
|
+
const reservation = await reserveMarkitdownCredits(
|
|
493
|
+
sbAdmin,
|
|
494
|
+
billingWsId,
|
|
495
|
+
ctx,
|
|
496
|
+
{
|
|
497
|
+
...(sourceUrl
|
|
498
|
+
? { sourceType: 'youtube_url', sourceUrl }
|
|
499
|
+
: {
|
|
500
|
+
sourceType: 'storage_file',
|
|
501
|
+
targetPath,
|
|
502
|
+
selectedFileName: stripTimestampPrefix(selectedFileName),
|
|
503
|
+
}),
|
|
504
|
+
}
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
if (!reservation.ok) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: reservation.error,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const reservationId = reservation.reservationId;
|
|
515
|
+
let shouldReleaseReservation = true;
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
let signedReadUrl = '';
|
|
519
|
+
if (!sourceUrl) {
|
|
520
|
+
const { data: signedReadData, error: signedReadError } =
|
|
521
|
+
await sbAdmin.storage
|
|
522
|
+
.from('workspaces')
|
|
523
|
+
.createSignedUrl(targetPath, 120);
|
|
524
|
+
|
|
525
|
+
signedReadUrl = signedReadData?.signedUrl ?? '';
|
|
526
|
+
|
|
527
|
+
if (signedReadError || !signedReadUrl) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
error: `Failed to create signed download URL: ${signedReadError?.message ?? 'No URL returned'}`,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const markitdownTimeoutMs = resolveMarkitdownTimeoutMs();
|
|
536
|
+
const abortController = new AbortController();
|
|
537
|
+
const timeoutId = setTimeout(
|
|
538
|
+
() => abortController.abort(),
|
|
539
|
+
markitdownTimeoutMs
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
let conversionResponse: Response;
|
|
543
|
+
try {
|
|
544
|
+
conversionResponse = await fetch(markitdownUrl, {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
headers: {
|
|
547
|
+
Authorization: `Bearer ${markitdownSecret}`,
|
|
548
|
+
'Content-Type': 'application/json',
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify(
|
|
551
|
+
sourceUrl
|
|
552
|
+
? {
|
|
553
|
+
url: sourceUrl,
|
|
554
|
+
filename: fileNameArg || 'youtube.md',
|
|
555
|
+
enable_plugins: true,
|
|
556
|
+
}
|
|
557
|
+
: {
|
|
558
|
+
signed_url: signedReadUrl,
|
|
559
|
+
filename: stripTimestampPrefix(selectedFileName),
|
|
560
|
+
enable_plugins: true,
|
|
561
|
+
}
|
|
562
|
+
),
|
|
563
|
+
signal: abortController.signal,
|
|
564
|
+
});
|
|
565
|
+
} catch (error) {
|
|
566
|
+
const message =
|
|
567
|
+
error instanceof Error && error.name === 'AbortError'
|
|
568
|
+
? `MarkItDown conversion timed out after ${markitdownTimeoutMs}ms.`
|
|
569
|
+
: 'Failed to reach MarkItDown conversion service.';
|
|
570
|
+
console.error('MarkItDown conversion request failed:', error);
|
|
571
|
+
return { ok: false, error: message };
|
|
572
|
+
} finally {
|
|
573
|
+
clearTimeout(timeoutId);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (!conversionResponse.ok) {
|
|
577
|
+
const rawBody = await conversionResponse.text().catch(() => '');
|
|
578
|
+
const safeMessage = rawBody.replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
579
|
+
console.error('MarkItDown conversion failed:', {
|
|
580
|
+
status: conversionResponse.status,
|
|
581
|
+
body: safeMessage,
|
|
582
|
+
});
|
|
583
|
+
return {
|
|
584
|
+
ok: false,
|
|
585
|
+
error: `MarkItDown conversion failed (status ${conversionResponse.status}).`,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
let payload: { ok?: boolean; markdown?: unknown; title?: unknown };
|
|
590
|
+
try {
|
|
591
|
+
payload = (await conversionResponse.json()) as {
|
|
592
|
+
ok?: boolean;
|
|
593
|
+
markdown?: unknown;
|
|
594
|
+
title?: unknown;
|
|
595
|
+
};
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.error('MarkItDown returned invalid JSON response:', error);
|
|
598
|
+
return { ok: false, error: 'MarkItDown conversion failed.' };
|
|
599
|
+
}
|
|
600
|
+
const markdown =
|
|
601
|
+
typeof payload.markdown === 'string' ? payload.markdown.trim() : '';
|
|
602
|
+
|
|
603
|
+
if (!markdown) {
|
|
604
|
+
return { ok: false, error: 'MarkItDown returned empty markdown.' };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const wasTruncated = markdown.length > maxCharacters;
|
|
608
|
+
const finalMarkdown = wasTruncated
|
|
609
|
+
? `${markdown.slice(0, maxCharacters)}\n\n[...truncated for token safety...]`
|
|
610
|
+
: markdown;
|
|
611
|
+
|
|
612
|
+
const deduction = await commitMarkitdownCredits(sbAdmin, reservationId, {
|
|
613
|
+
wsId: billingWsId,
|
|
614
|
+
userId: ctx.userId,
|
|
615
|
+
...(sourceUrl
|
|
616
|
+
? { sourceType: 'youtube_url', sourceUrl }
|
|
617
|
+
: {
|
|
618
|
+
sourceType: 'storage_file',
|
|
619
|
+
targetPath,
|
|
620
|
+
selectedFileName: stripTimestampPrefix(selectedFileName),
|
|
621
|
+
}),
|
|
622
|
+
markdownLength: markdown.length,
|
|
623
|
+
maxCharacters,
|
|
624
|
+
truncated: wasTruncated,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
if (!deduction.ok) {
|
|
628
|
+
console.error(
|
|
629
|
+
'MarkItDown: conversion succeeded but reserved credit commit failed:',
|
|
630
|
+
{
|
|
631
|
+
wsId: ctx.wsId,
|
|
632
|
+
userId: ctx.userId,
|
|
633
|
+
source: sourceLabel,
|
|
634
|
+
reservationId,
|
|
635
|
+
error: deduction.error,
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
error: deduction.error,
|
|
642
|
+
title: typeof payload.title === 'string' ? payload.title : null,
|
|
643
|
+
fileName: sourceUrl
|
|
644
|
+
? fileNameArg || null
|
|
645
|
+
: stripTimestampPrefix(selectedFileName),
|
|
646
|
+
storagePath: sourceUrl ? null : targetPath,
|
|
647
|
+
url: sourceUrl,
|
|
648
|
+
truncated: wasTruncated,
|
|
649
|
+
creditDeductionError: deduction.error,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
shouldReleaseReservation = false;
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
ok: true,
|
|
657
|
+
markdown: finalMarkdown,
|
|
658
|
+
title: typeof payload.title === 'string' ? payload.title : null,
|
|
659
|
+
fileName: sourceUrl
|
|
660
|
+
? fileNameArg || null
|
|
661
|
+
: stripTimestampPrefix(selectedFileName),
|
|
662
|
+
storagePath: sourceUrl ? null : targetPath,
|
|
663
|
+
url: sourceUrl,
|
|
664
|
+
creditsCharged: MARKITDOWN_COST_CREDITS,
|
|
665
|
+
remainingCredits: deduction.remainingCredits,
|
|
666
|
+
truncated: wasTruncated,
|
|
667
|
+
};
|
|
668
|
+
} finally {
|
|
669
|
+
if (shouldReleaseReservation) {
|
|
670
|
+
await releaseMarkitdownCredits(sbAdmin, reservationId, {
|
|
671
|
+
wsId: ctx.wsId,
|
|
672
|
+
userId: ctx.userId,
|
|
673
|
+
...(sourceUrl
|
|
674
|
+
? { sourceType: 'youtube_url', sourceUrl }
|
|
675
|
+
: {
|
|
676
|
+
sourceType: 'storage_file',
|
|
677
|
+
targetPath,
|
|
678
|
+
selectedFileName: stripTimestampPrefix(selectedFileName),
|
|
679
|
+
}),
|
|
680
|
+
reason: 'markitdown_execution_failed',
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|