vibefast-cli 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite +0 -0
  2. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite-shm +0 -0
  3. package/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite-wal +0 -0
  4. package/.wrangler/state/v3/r2/vibefast-recipes/blobs/177b5d7279681c1bec396cafe63779a2d89eaf538109e55733147727276e2b9f0000019a81f04ba2 +0 -0
  5. package/.wrangler/state/v3/r2/vibefast-recipes/blobs/4fe398bba6e2d5f13b569bc1be4244e696d86caa04c323db2d9fb0b9381c508f0000019a81f0503f +0 -0
  6. package/.wrangler/state/v3/r2/vibefast-recipes/blobs/f68f19a655380ac7fb575eb49c0623cde74046261ed89c498ba5107b8aacde9d0000019a81f05484 +0 -0
  7. package/package.json +1 -1
  8. package/recipes/chatbot/packages/backend/convex/agents.ts +116 -0
  9. package/recipes/chatbot/packages/backend/convex/chatbotAgent.ts +1085 -0
  10. package/recipes/chatbot/packages/backend/convex/chatbotHistory.ts +307 -0
  11. package/recipes/chatbot/packages/backend/convex/lib/rateLimit.ts +100 -0
  12. package/recipes/chatbot/packages/backend/convex/lib/telemetry.ts +29 -0
  13. package/recipes/chatbot/packages/backend/convex/ragKnowledge.ts +714 -0
  14. package/recipes/chatbot/packages/backend/convex/tools/index.ts +18 -0
  15. package/recipes/chatbot/packages/backend/convex/tools/knowledgeRetrieval.ts +92 -0
  16. package/recipes/chatbot/packages/backend/convex/tools/tavilySearch.ts +83 -0
  17. package/recipes/chatbot/packages/backend/convex/tools/userProfile.ts +72 -0
  18. package/recipes/chatbot/recipe.json +89 -1
  19. package/recipes/chatbot@latest.zip +0 -0
  20. package/recipes/image-generator/packages/backend/convex/imageGeneration/index.ts +12 -0
  21. package/recipes/image-generator/packages/backend/convex/imageGeneratorFunctions.ts +290 -0
  22. package/recipes/image-generator/recipe.json +41 -1
  23. package/recipes/image-generator@latest.zip +0 -0
  24. package/recipes/voice-bot/packages/backend/convex/router.ts +81 -0
  25. package/recipes/voice-bot/recipe.json +48 -1
  26. package/recipes/voice-bot@latest.zip +0 -0
@@ -0,0 +1,18 @@
1
+ // Central export for all available AI tools
2
+ // This file makes it easy to add more tools in the future
3
+
4
+ import { knowledgeRetrievalTool } from './knowledgeRetrieval';
5
+ import { tavilySearchTool } from './tavilySearch';
6
+ import { userProfileTool } from './userProfile';
7
+
8
+ // Export all tools in a single object for easy access
9
+ export const allTools = {
10
+ knowledgeRetrieval: knowledgeRetrievalTool,
11
+ tavilySearch: tavilySearchTool,
12
+ userProfile: userProfileTool,
13
+ } as const;
14
+
15
+ // Export individual tools for specific use cases
16
+ export { knowledgeRetrievalTool } from './knowledgeRetrieval';
17
+ export { tavilySearchTool } from './tavilySearch';
18
+ export { userProfileTool } from './userProfile';
@@ -0,0 +1,92 @@
1
+ import { createTool } from '@convex-dev/agent';
2
+ import { z } from 'zod';
3
+
4
+ import { api } from '../_generated/api';
5
+
6
+ export const knowledgeRetrievalTool = createTool({
7
+ description:
8
+ 'Search uploaded knowledge files and synthesize an answer with citations.',
9
+ args: z.object({
10
+ query: z.string().min(1).describe('Question or topic to search.'),
11
+ scope: z
12
+ .enum(['personal', 'global'])
13
+ .default('personal')
14
+ .describe(
15
+ 'Whether to search personal uploads or the shared global knowledge base.',
16
+ ),
17
+ filter: z
18
+ .union([
19
+ z.object({
20
+ type: z.literal('filename'),
21
+ value: z.string(),
22
+ }),
23
+ z.object({
24
+ type: z.literal('category'),
25
+ value: z.union([z.string(), z.null()]),
26
+ }),
27
+ ])
28
+ .optional()
29
+ .describe('Optional filter to narrow search by filename or category.'),
30
+ limit: z
31
+ .number()
32
+ .min(1)
33
+ .max(20)
34
+ .default(10)
35
+ .describe('Maximum number of chunks to retrieve.'),
36
+ chunkContext: z
37
+ .object({
38
+ before: z.number().min(0).max(5).default(1),
39
+ after: z.number().min(0).max(5).default(1),
40
+ })
41
+ .default({ before: 1, after: 1 })
42
+ .describe('Number of neighboring chunks to include around matches.'),
43
+ }),
44
+ handler: async (ctx, args): Promise<string> => {
45
+ const prompt = args.query.trim();
46
+ if (!prompt) {
47
+ return 'Please provide a non-empty question for knowledge retrieval.';
48
+ }
49
+
50
+ const filter =
51
+ args.filter?.type === 'filename'
52
+ ? {
53
+ name: 'filename' as const,
54
+ value: args.filter.value,
55
+ }
56
+ : args.filter?.type === 'category'
57
+ ? {
58
+ name: 'category' as const,
59
+ value: args.filter.value,
60
+ }
61
+ : undefined;
62
+
63
+ const chunkContext = {
64
+ before: args.chunkContext?.before ?? 1,
65
+ after: args.chunkContext?.after ?? 1,
66
+ };
67
+
68
+ const result = (await ctx.runAction(api.ragKnowledge.askKnowledge, {
69
+ prompt,
70
+ globalNamespace: args.scope === 'global',
71
+ limit: args.limit,
72
+ chunkContext,
73
+ filter,
74
+ })) as {
75
+ answer: string;
76
+ files?: { filename: string; url: string | null }[];
77
+ };
78
+
79
+ if (!result.files || result.files.length === 0) {
80
+ return `${result.answer}\n\n_No supporting files were located._`;
81
+ }
82
+
83
+ const references = result.files
84
+ .map((file: { filename: string; url: string | null }, index: number) => {
85
+ const label = `[${index + 1}] ${file.filename}`;
86
+ return file.url ? `${label} — ${file.url}` : label;
87
+ })
88
+ .join('\n');
89
+
90
+ return `${result.answer}\n\n**References**\n${references}`;
91
+ },
92
+ });
@@ -0,0 +1,83 @@
1
+ import { createTool } from '@convex-dev/agent';
2
+ import { z } from 'zod';
3
+
4
+ import { internal } from '../_generated/api';
5
+ import type { Id } from '../_generated/dataModel';
6
+
7
+ /**
8
+ * Tavily search tool exposed to the Convex agent runtime.
9
+ * Uses the direct HTTP API to stay compatible with the Convex execution environment.
10
+ */
11
+ export const tavilySearchTool = createTool({
12
+ description:
13
+ 'Search the web with Tavily to retrieve up-to-date information. Best for news, recent events, or factual lookups.',
14
+ args: z.object({
15
+ query: z.string().describe('The query to send to Tavily.'),
16
+ }),
17
+ handler: async (ctx, { query }) => {
18
+ const userId = ctx.userId as Id<'users'> | undefined;
19
+ if (userId) {
20
+ await ctx.runMutation(internal.rateLimit.enforceTool, { userId });
21
+ }
22
+
23
+ const apiKey = process.env.TAVILY_API_KEY;
24
+ if (!apiKey) {
25
+ return 'Tavily search is unavailable because TAVILY_API_KEY is not configured.';
26
+ }
27
+
28
+ try {
29
+ const response = await fetch('https://api.tavily.com/search', {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/json',
33
+ Authorization: `Bearer ${apiKey}`,
34
+ },
35
+ body: JSON.stringify({
36
+ query,
37
+ max_results: 5,
38
+ search_depth: 'basic',
39
+ include_answer: false,
40
+ include_images: false,
41
+ }),
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const errorText = await response.text();
46
+ throw new Error(
47
+ `Tavily API error: ${response.status} ${response.statusText} - ${errorText}`,
48
+ );
49
+ }
50
+
51
+ const searchResult = await response.json();
52
+
53
+ if (!searchResult.results || searchResult.results.length === 0) {
54
+ return `No Tavily results found for “${query}”.`;
55
+ }
56
+
57
+ const formattedResults = searchResult.results
58
+ .map(
59
+ (
60
+ result: {
61
+ title?: string;
62
+ url?: string;
63
+ content?: string;
64
+ },
65
+ index: number,
66
+ ) => {
67
+ const title = result.title ?? 'Untitled';
68
+ const url = result.url ?? 'No URL';
69
+ const content = result.content?.trim() || 'No summary available.';
70
+ return `${index + 1}. **${title}**\n URL: ${url}\n Summary: ${content}`;
71
+ },
72
+ )
73
+ .join('\n\n');
74
+
75
+ return `🌐 **Tavily Search Results for “${query}”**\n\n${formattedResults}`;
76
+ } catch (error) {
77
+ console.error('[Tavily Search Tool] Error performing search:', error);
78
+ return `Tavily search failed: ${
79
+ error instanceof Error ? error.message : 'Unknown error'
80
+ }`;
81
+ }
82
+ },
83
+ });
@@ -0,0 +1,72 @@
1
+ import { createTool } from '@convex-dev/agent';
2
+ import { z } from 'zod';
3
+
4
+ import { internal } from '../_generated/api';
5
+ import type { Id } from '../_generated/dataModel';
6
+
7
+ type PublicUserFields = {
8
+ name: string | null;
9
+ email: string | null;
10
+ credits: number;
11
+ joinedAt: string | null;
12
+ emailVerified: boolean;
13
+ };
14
+
15
+ type UserProfileToolResult =
16
+ | {
17
+ found: false;
18
+ reason: string;
19
+ }
20
+ | {
21
+ found: true;
22
+ profile: PublicUserFields;
23
+ guidance: string;
24
+ };
25
+
26
+ /**
27
+ * Tool that surfaces the signed-in user's profile information so the agent can
28
+ * answer personalized questions (e.g. “How many credits do I have?”).
29
+ *
30
+ * The handler returns a sanitized payload – no phone numbers or internal IDs –
31
+ * and gracefully handles anonymous or missing profiles.
32
+ */
33
+ export const userProfileTool = createTool({
34
+ description:
35
+ 'Fetch the current signed-in user profile (name, email, credits, join date). Use when the user asks about their account details.',
36
+ args: z
37
+ .object({})
38
+ .describe('No input is required; the tool looks up the current user.'),
39
+ handler: async (ctx, _args): Promise<UserProfileToolResult> => {
40
+ const userId = ctx.userId as Id<'users'> | undefined;
41
+
42
+ if (!userId) {
43
+ return {
44
+ found: false,
45
+ reason:
46
+ 'There is no signed-in user associated with this conversation, so profile data is unavailable.',
47
+ };
48
+ }
49
+
50
+ await ctx.runMutation(internal.rateLimit.enforceTool, {
51
+ userId,
52
+ });
53
+
54
+ const profile = await ctx.runQuery(internal.sharedUsers.getPublicProfile, {
55
+ userId,
56
+ });
57
+
58
+ if (!profile) {
59
+ return {
60
+ found: false,
61
+ reason: 'Could not locate the user record linked to this conversation.',
62
+ };
63
+ }
64
+
65
+ return {
66
+ found: true,
67
+ profile,
68
+ guidance:
69
+ 'Summarize the profile in natural language. If fields are null, mention they are not provided. Use credits to answer balance questions.',
70
+ };
71
+ },
72
+ });
@@ -14,6 +14,46 @@
14
14
  {
15
15
  "from": "packages/backend/convex/chatbot",
16
16
  "to": "packages/backend/convex/chatbot"
17
+ },
18
+ {
19
+ "from": "packages/backend/convex/chatbotAgent.ts",
20
+ "to": "packages/backend/convex/chatbotAgent.ts"
21
+ },
22
+ {
23
+ "from": "packages/backend/convex/chatbotHistory.ts",
24
+ "to": "packages/backend/convex/chatbotHistory.ts"
25
+ },
26
+ {
27
+ "from": "packages/backend/convex/agents.ts",
28
+ "to": "packages/backend/convex/agents.ts"
29
+ },
30
+ {
31
+ "from": "packages/backend/convex/lib/rateLimit.ts",
32
+ "to": "packages/backend/convex/lib/rateLimit.ts"
33
+ },
34
+ {
35
+ "from": "packages/backend/convex/lib/telemetry.ts",
36
+ "to": "packages/backend/convex/lib/telemetry.ts"
37
+ },
38
+ {
39
+ "from": "packages/backend/convex/ragKnowledge.ts",
40
+ "to": "packages/backend/convex/ragKnowledge.ts"
41
+ },
42
+ {
43
+ "from": "packages/backend/convex/tools/index.ts",
44
+ "to": "packages/backend/convex/tools/index.ts"
45
+ },
46
+ {
47
+ "from": "packages/backend/convex/tools/knowledgeRetrieval.ts",
48
+ "to": "packages/backend/convex/tools/knowledgeRetrieval.ts"
49
+ },
50
+ {
51
+ "from": "packages/backend/convex/tools/tavilySearch.ts",
52
+ "to": "packages/backend/convex/tools/tavilySearch.ts"
53
+ },
54
+ {
55
+ "from": "packages/backend/convex/tools/userProfile.ts",
56
+ "to": "packages/backend/convex/tools/userProfile.ts"
17
57
  }
18
58
  ],
19
59
  "nav": {
@@ -33,5 +73,53 @@
33
73
  "react-native-reanimated",
34
74
  "react-native-safe-area-context"
35
75
  ]
36
- }
76
+ },
77
+ "env": [
78
+ {
79
+ "key": "OPENAI_API_KEY",
80
+ "description": "OpenAI API key used by the backend agent and knowledge tools.",
81
+ "example": "sk-abc123def456",
82
+ "link": "https://platform.openai.com/account/api-keys"
83
+ },
84
+ {
85
+ "key": "GOOGLE_GENERATIVE_AI_API_KEY",
86
+ "description": "Google Generative AI key that unlocks the Gemini-backed assistant.",
87
+ "example": "AIzaSyExampleGeneratedKey",
88
+ "link": "https://console.cloud.google.com/apis/credentials"
89
+ },
90
+ {
91
+ "key": "TAVILY_API_KEY",
92
+ "description": "API key for Tavily web search results used by the agent tools.",
93
+ "example": "tavily_abc123",
94
+ "link": "https://app.tavily.com/"
95
+ },
96
+ {
97
+ "key": "CHAT_LATENCY_TELEMETRY_MODE",
98
+ "description": "Optional telemetry mode to sample chat latency (off | sample | debug).",
99
+ "example": "sample"
100
+ }
101
+ ],
102
+ "manualSteps": [
103
+ {
104
+ "title": "Create an OpenAI API key",
105
+ "description": "Sign into https://platform.openai.com and create/copy a server API key for the chat agent.",
106
+ "link": "https://platform.openai.com/account/api-keys"
107
+ },
108
+ {
109
+ "title": "Create a Google Generative AI key",
110
+ "description": "Enable the Generative AI APIs in the Google Cloud Console and issue a new API key.",
111
+ "link": "https://console.cloud.google.com/apis/credentials"
112
+ },
113
+ {
114
+ "title": "Create a Tavily search key",
115
+ "description": "Register at Tavily and grab an API key so the chatbot can query the web.",
116
+ "link": "https://app.tavily.com/"
117
+ },
118
+ {
119
+ "title": "Store the keys in your .env",
120
+ "description": "Add the required AI/search env variables so Convex can call OpenAI, Google, and Tavily.",
121
+ "file": ".env",
122
+ "content": "OPENAI_API_KEY=sk-...\nGOOGLE_GENERATIVE_AI_API_KEY=...\nTAVILY_API_KEY=...\nCHAT_LATENCY_TELEMETRY_MODE=sample"
123
+ }
124
+ ]
37
125
  }
Binary file
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Image Generation Domain Aggregator
3
+ *
4
+ * Single entry point for all image generation Convex functions.
5
+ * Re-exports functions from imageGeneratorFunctions.ts to decouple
6
+ * frontend from internal file structure.
7
+ */
8
+
9
+ export {
10
+ generateImageAction,
11
+ saveImageRecord,
12
+ } from '../imageGeneratorFunctions';
@@ -0,0 +1,290 @@
1
+ import { getAuthUserId } from '@convex-dev/auth/server';
2
+ import { experimental_generateImage as generateImage } from 'ai';
3
+ import { createOpenAI } from '@ai-sdk/openai';
4
+ import { GoogleGenAI } from '@google/genai';
5
+ import { v } from 'convex/values';
6
+
7
+ import { internal } from './_generated/api';
8
+ import type { Id } from './_generated/dataModel';
9
+ import { action, internalMutation } from './_generated/server';
10
+
11
+ const DEFAULT_OPENAI_MODEL = 'dall-e-3';
12
+ const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash-image';
13
+
14
+ const SUPPORTED_OPENAI_MODELS = new Set<string>(['dall-e-3', 'gpt-image-1']);
15
+ const SUPPORTED_GEMINI_MODELS = new Set<string>([
16
+ 'gemini-2.5-flash-image',
17
+ 'gemini-2.0-flash-image',
18
+ ]);
19
+
20
+ type SupportedProvider = 'openai' | 'gemini';
21
+
22
+ const isSupportedProvider = (value: string): value is SupportedProvider =>
23
+ value === 'openai' || value === 'gemini';
24
+
25
+ const resolveProvider = (
26
+ provider: string | null | undefined,
27
+ ): SupportedProvider => {
28
+ if (provider && isSupportedProvider(provider)) {
29
+ return provider;
30
+ }
31
+ return 'gemini';
32
+ };
33
+
34
+ const resolveModel = (
35
+ provider: SupportedProvider,
36
+ model: string | null | undefined,
37
+ ): string => {
38
+ if (provider === 'openai') {
39
+ return model && SUPPORTED_OPENAI_MODELS.has(model)
40
+ ? model
41
+ : DEFAULT_OPENAI_MODEL;
42
+ }
43
+ return model && SUPPORTED_GEMINI_MODELS.has(model)
44
+ ? model
45
+ : DEFAULT_GEMINI_MODEL;
46
+ };
47
+
48
+ const generateWithOpenAI = async (prompt: string, model: string) => {
49
+ const apiKey = process.env.OPENAI_API_KEY;
50
+ if (!apiKey) {
51
+ throw new Error('OPENAI_API_KEY environment variable is required');
52
+ }
53
+
54
+ const openai = createOpenAI({ apiKey });
55
+ const result = await generateImage({
56
+ model: openai.image(model),
57
+ prompt,
58
+ size: '1024x1024',
59
+ });
60
+
61
+ const imageResult = result.image ?? result.images?.[0];
62
+ if (!imageResult) {
63
+ throw new Error('OpenAI image generation did not return any image data.');
64
+ }
65
+
66
+ const mimeType = imageResult.mimeType ?? 'image/png';
67
+ const base64Data =
68
+ imageResult.base64 ??
69
+ (imageResult.uint8Array
70
+ ? Buffer.from(imageResult.uint8Array).toString('base64')
71
+ : null);
72
+
73
+ if (!base64Data) {
74
+ throw new Error('OpenAI image generation returned no image payload.');
75
+ }
76
+
77
+ return {
78
+ imageDataUri: `data:${mimeType};base64,${base64Data}`,
79
+ mimeType,
80
+ };
81
+ };
82
+
83
+ const generateWithGemini = async (prompt: string, model: string) => {
84
+ const apiKey = process.env.GEMINI_API_KEY;
85
+ if (!apiKey) {
86
+ throw new Error('GEMINI_API_KEY environment variable is required');
87
+ }
88
+
89
+ const ai = new GoogleGenAI({ apiKey });
90
+ const response = await ai.models.generateContentStream({
91
+ model,
92
+ config: {
93
+ responseModalities: ['IMAGE', 'TEXT'],
94
+ // imageConfig: {
95
+ // imageSize: "1K",
96
+ // },
97
+ },
98
+ contents: [
99
+ {
100
+ role: 'user',
101
+ parts: [
102
+ {
103
+ text: prompt,
104
+ },
105
+ ],
106
+ },
107
+ ],
108
+ });
109
+
110
+ let imageInlineData: {
111
+ data?: string;
112
+ mimeType?: string;
113
+ } | null = null;
114
+ const textChunks: string[] = [];
115
+
116
+ for await (const chunk of response) {
117
+ const parts = chunk.candidates?.[0]?.content?.parts ?? [];
118
+ for (const part of parts) {
119
+ if (part.inlineData) {
120
+ imageInlineData = part.inlineData;
121
+ break;
122
+ }
123
+ if (part.text) {
124
+ textChunks.push(part.text);
125
+ }
126
+ }
127
+ if (imageInlineData) {
128
+ break;
129
+ }
130
+ }
131
+
132
+ if (!imageInlineData?.data) {
133
+ const textSummary = textChunks.join(' ').trim();
134
+ throw new Error(
135
+ textSummary
136
+ ? `Gemini image generation did not return any image data. Response: ${textSummary}`
137
+ : 'Gemini image generation did not return any image data.',
138
+ );
139
+ }
140
+
141
+ const mimeType = imageInlineData.mimeType ?? 'image/png';
142
+ const base64Data = imageInlineData.data;
143
+
144
+ return {
145
+ imageDataUri: `data:${mimeType};base64,${base64Data}`,
146
+ mimeType,
147
+ };
148
+ };
149
+
150
+ export const saveImageRecord = internalMutation({
151
+ args: v.object({
152
+ userId: v.id('users'),
153
+ prompt: v.string(),
154
+ storageId: v.id('_storage'),
155
+ provider: v.string(),
156
+ model: v.string(),
157
+ mimeType: v.optional(v.string()),
158
+ }),
159
+ returns: v.id('generatedImages'),
160
+ handler: async (
161
+ ctx,
162
+ { userId, prompt, storageId, provider, model, mimeType },
163
+ ) => {
164
+ console.log(`[saveImageRecord] Saving image record for user: ${userId}`);
165
+
166
+ const imageRecordId = await ctx.db.insert('generatedImages', {
167
+ userId,
168
+ prompt,
169
+ storageId,
170
+ provider,
171
+ model,
172
+ mimeType,
173
+ });
174
+
175
+ console.log(
176
+ `[saveImageRecord] Image record created with ID: ${imageRecordId}`,
177
+ );
178
+ return imageRecordId;
179
+ },
180
+ });
181
+
182
+ /**
183
+ * Generate an image based on user prompt and provider selection
184
+ */
185
+ export const generateImageAction = action({
186
+ args: v.object({
187
+ prompt: v.string(),
188
+ provider: v.string(), // supports 'openai' and 'gemini'
189
+ model: v.string(),
190
+ }),
191
+ returns: v.object({
192
+ imageDataUri: v.string(),
193
+ mimeType: v.string(),
194
+ prompt: v.string(),
195
+ provider: v.string(),
196
+ model: v.string(),
197
+ storageId: v.optional(v.id('_storage')),
198
+ imageId: v.optional(v.id('generatedImages')),
199
+ }),
200
+ handler: async (ctx, { prompt, provider, model }) => {
201
+ // Verify user authentication
202
+ const identity = await ctx.auth.getUserIdentity();
203
+ if (!identity) {
204
+ throw new Error(
205
+ 'Unauthenticated: User must be logged in to generate images',
206
+ );
207
+ }
208
+
209
+ const userId = await getAuthUserId(ctx);
210
+ if (!userId) {
211
+ throw new Error('User record not found for authenticated identity');
212
+ }
213
+ console.log(`[generateImageAction] Provider: ${provider}, Model: ${model}`);
214
+ const resolvedProvider = resolveProvider(provider);
215
+ const resolvedModel = resolveModel(resolvedProvider, model);
216
+
217
+ console.log(
218
+ `[generateImageAction] Starting image generation | provider=${resolvedProvider}, model=${resolvedModel}`,
219
+ );
220
+
221
+ try {
222
+ const result =
223
+ resolvedProvider === 'openai'
224
+ ? await generateWithOpenAI(prompt, resolvedModel)
225
+ : await generateWithGemini(prompt, resolvedModel);
226
+
227
+ console.log(
228
+ `[generateImageAction] Image generated successfully. provider=${resolvedProvider}, model=${resolvedModel}, mimeType=${result.mimeType}, dataUri length=${result.imageDataUri.length}`,
229
+ );
230
+
231
+ let storageId: Id<'_storage'> | null = null;
232
+ try {
233
+ const base64Match = result.imageDataUri.match(
234
+ /^data:(?<mime>[^;]+);base64,(?<payload>.+)$/,
235
+ );
236
+ if (!base64Match?.groups?.payload) {
237
+ throw new Error('Invalid image data URI returned by provider');
238
+ }
239
+ const buffer = Buffer.from(base64Match.groups.payload, 'base64');
240
+ const uint8Array = new Uint8Array(buffer);
241
+ const blob = new Blob([uint8Array], { type: result.mimeType });
242
+ storageId = await ctx.storage.store(blob);
243
+ } catch (storageError) {
244
+ console.error(
245
+ '[generateImageAction] Failed to persist generated image to storage',
246
+ storageError,
247
+ );
248
+ }
249
+
250
+ let imageRecordId: Id<'generatedImages'> | null = null;
251
+ if (storageId) {
252
+ try {
253
+ imageRecordId = await ctx.runMutation(
254
+ internal.imageGeneration.index.saveImageRecord,
255
+ {
256
+ userId,
257
+ prompt,
258
+ storageId,
259
+ provider: resolvedProvider,
260
+ model: resolvedModel,
261
+ mimeType: result.mimeType,
262
+ },
263
+ );
264
+ } catch (recordError) {
265
+ console.error(
266
+ '[generateImageAction] Failed to save generated image metadata',
267
+ recordError,
268
+ );
269
+ }
270
+ }
271
+
272
+ return {
273
+ imageDataUri: result.imageDataUri,
274
+ mimeType: result.mimeType,
275
+ prompt,
276
+ provider: resolvedProvider,
277
+ model: resolvedModel,
278
+ storageId: storageId ?? undefined,
279
+ imageId: imageRecordId ?? undefined,
280
+ };
281
+ } catch (error) {
282
+ console.error('[generateImageAction] Error generating image:', error);
283
+ throw new Error(
284
+ `Image generation failed: ${
285
+ error instanceof Error ? error.message : 'Unknown error'
286
+ }`,
287
+ );
288
+ }
289
+ },
290
+ });