Reubencf commited on
Commit
67f0ec9
·
1 Parent(s): c1b5f7a

PPTX export fixed

Browse files
app/api/ai-edit-text/route.ts CHANGED
@@ -1,47 +1,17 @@
1
  /**
2
  * AI Text Editing Endpoint
3
  *
4
- * Accepts a piece of slide text and an `action` verb, then uses an AI model to
5
- * transform the text according to the chosen action. The endpoint first tries
6
- * the user's HuggingFace API key (if present); if that fails or no key is
7
- * available it falls back to Google Gemini (free tier).
8
- *
9
- * Supported actions:
10
- * - `"refine"` — Improve professionalism and clarity while preserving meaning.
11
- * - `"change"` — Rewrite with different words but identical information.
12
- * - `"expand"` — Elaborate with more detail, examples, and context.
13
- *
14
- * Method: POST
15
- * Body: `{ text: string, action: "refine" | "change" | "expand" }`
16
- * Returns: `{ text: string }` — the transformed text.
17
- *
18
- * Authentication:
19
- * HF token is read (in priority order) from:
20
- * 1. `x-hf-token` request header
21
- * 2. `hf_api_key` httpOnly cookie
22
- * If neither is present Gemini is used automatically.
23
  */
24
 
25
  import { NextRequest, NextResponse } from 'next/server';
26
- import { GeminiClient } from '@/lib/gemini-client';
27
  import { HFClient } from '@/lib/hf-client';
 
28
 
29
- /**
30
- * POST /api/ai-edit-text
31
- *
32
- * Transforms `text` according to the specified `action`. The action determines
33
- * the editing prompt sent to the AI model.
34
- *
35
- * @param req - NextRequest with JSON body `{ text: string, action: string }`.
36
- * @returns 200 `{ text: string }` on success,
37
- * 400 on missing / invalid inputs,
38
- * 500 when both AI providers fail.
39
- */
40
  export async function POST(req: NextRequest) {
41
  try {
42
  const { text, action } = await req.json();
43
 
44
- // Both the original text and the action type are required
45
  if (!text) {
46
  return NextResponse.json({ error: 'text required' }, { status: 400 });
47
  }
@@ -52,12 +22,8 @@ export async function POST(req: NextRequest) {
52
 
53
  let prompt = '';
54
 
55
- // Create prompts based on action with enhanced instructions
56
- // Each prompt variant includes detailed guidelines to produce consistent,
57
- // high-quality slide copy for the three supported editing modes.
58
  switch (action) {
59
  case 'refine':
60
- // Improve the text's professionalism and impact without changing its meaning
61
  prompt = `You are an expert presentation copywriter. Refine and improve the following slide text to make it more professional, clear, and impactful.
62
 
63
  Guidelines:
@@ -74,7 +40,6 @@ Return ONLY the refined text without any explanations, comments, or additional f
74
  break;
75
 
76
  case 'change':
77
- // Rewrite with fresh wording while keeping all information intact
78
  prompt = `You are an expert presentation copywriter. Rewrite the following slide text with fresh wording while preserving the exact same meaning and all key points.
79
 
80
  Guidelines:
@@ -91,7 +56,6 @@ Return ONLY the rewritten text without any explanations, comments, or additional
91
  break;
92
 
93
  case 'expand':
94
- // Elaborate on the text with more detail, examples, and context
95
  prompt = `You are an expert presentation copywriter. Expand the following slide text to be more detailed, comprehensive, and valuable to the audience.
96
 
97
  Guidelines:
@@ -109,56 +73,27 @@ Return ONLY the expanded text without any explanations, comments, or additional
109
  break;
110
 
111
  default:
112
- // Reject unknown action strings early
113
  return NextResponse.json({ error: 'invalid action' }, { status: 400 });
114
  }
115
 
116
- // editedText starts as the original — only replaced when an AI call succeeds
117
- let editedText = text; // Default fallback
118
-
119
- // ---------------------------------------------------------------------------
120
- // Provider selection: HuggingFace first, Gemini as fallback
121
- // ---------------------------------------------------------------------------
122
-
123
- // Get HF token - check header first (user's API key), then cookie
124
- // This ensures the user's HuggingFace inference credits are properly deducted
125
  let hfToken = req.headers.get('x-hf-token');
 
126
  if (!hfToken) {
127
- // Fall back to the httpOnly cookie set during HF authentication
128
- hfToken = req.cookies.get('hf_api_key')?.value || null;
129
  }
 
130
 
131
- if (hfToken) {
132
- // Use HuggingFace with user's API key
133
- try {
134
- // DeepSeek V3.1 is used for editing as it produces clean, well-scoped output
135
- const hf = new HFClient({ apiKey: hfToken, model: 'deepseek-ai/DeepSeek-V3.1' });
136
- const response = await hf.generateSlideContent(prompt);
137
- editedText = response.trim(); // Remove any leading/trailing whitespace
138
- } catch (error) {
139
- console.error('HF editing error:', error);
140
- // HF call failed — fall through to Gemini fallback below
141
- }
142
  }
143
 
144
- // Use Gemini as fallback or if no HF token
145
- // Only attempt if editedText has not been updated by the HF call
146
- if (editedText === text) {
147
- try {
148
- const gemini = new GeminiClient();
149
- const response = await gemini.generateSlideContent(prompt);
150
- editedText = response.trim();
151
- } catch (error) {
152
- console.error('Gemini editing error:', error);
153
- // Both providers failed — return an error to the client
154
- return NextResponse.json({
155
- error: 'Failed to edit text with AI'
156
- }, { status: 500 });
157
- }
158
- }
159
 
160
- // Return the transformed text to the client
161
- return NextResponse.json({ text: editedText });
162
  } catch (e) {
163
  console.error('API error:', e);
164
  return NextResponse.json({ error: 'editing failed' }, { status: 500 });
 
1
  /**
2
  * AI Text Editing Endpoint
3
  *
4
+ * Uses the approved Llama 3.3 70B model to refine, rewrite, or expand slide text.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  */
6
 
7
  import { NextRequest, NextResponse } from 'next/server';
 
8
  import { HFClient } from '@/lib/hf-client';
9
+ import { LLAMA_PRESENTATION_MODEL } from '@/lib/ai-models';
10
 
 
 
 
 
 
 
 
 
 
 
 
11
  export async function POST(req: NextRequest) {
12
  try {
13
  const { text, action } = await req.json();
14
 
 
15
  if (!text) {
16
  return NextResponse.json({ error: 'text required' }, { status: 400 });
17
  }
 
22
 
23
  let prompt = '';
24
 
 
 
 
25
  switch (action) {
26
  case 'refine':
 
27
  prompt = `You are an expert presentation copywriter. Refine and improve the following slide text to make it more professional, clear, and impactful.
28
 
29
  Guidelines:
 
40
  break;
41
 
42
  case 'change':
 
43
  prompt = `You are an expert presentation copywriter. Rewrite the following slide text with fresh wording while preserving the exact same meaning and all key points.
44
 
45
  Guidelines:
 
56
  break;
57
 
58
  case 'expand':
 
59
  prompt = `You are an expert presentation copywriter. Expand the following slide text to be more detailed, comprehensive, and valuable to the audience.
60
 
61
  Guidelines:
 
73
  break;
74
 
75
  default:
 
76
  return NextResponse.json({ error: 'invalid action' }, { status: 400 });
77
  }
78
 
 
 
 
 
 
 
 
 
 
79
  let hfToken = req.headers.get('x-hf-token');
80
+ if (!hfToken) hfToken = req.cookies.get('hf_api_key')?.value || null;
81
  if (!hfToken) {
82
+ const authHeader = req.headers.get('authorization');
83
+ if (authHeader?.startsWith('Bearer ')) hfToken = authHeader.slice(7);
84
  }
85
+ if (!hfToken) hfToken = process.env.HF_TOKEN || process.env.HF_API_KEY || null;
86
 
87
+ if (!hfToken) {
88
+ return NextResponse.json({
89
+ error: 'HuggingFace authentication required for AI text editing'
90
+ }, { status: 401 });
 
 
 
 
 
 
 
91
  }
92
 
93
+ const hf = new HFClient({ apiKey: hfToken, model: LLAMA_PRESENTATION_MODEL });
94
+ const response = await hf.generateSlideContent(prompt, LLAMA_PRESENTATION_MODEL);
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ return NextResponse.json({ text: response.trim() });
 
97
  } catch (e) {
98
  console.error('API error:', e);
99
  return NextResponse.json({ error: 'editing failed' }, { status: 500 });
app/api/generate-slides/route.ts CHANGED
@@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from 'next/server';
12
  import { fetchImagesForSlides } from '@/lib/imageService';
13
  import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
14
  import type { GeneratedSlide } from '@/lib/slide-prompt';
 
15
 
16
  interface Slide {
17
  id: string;
@@ -28,13 +29,19 @@ export async function POST(request: NextRequest) {
28
  try {
29
  const { prompt, model } = await request.json();
30
 
31
- if (!prompt || !model) {
32
  return NextResponse.json(
33
- { error: 'Prompt and model are required' },
34
  { status: 400 }
35
  );
36
  }
37
 
 
 
 
 
 
 
38
  // Resolve HF token: header → cookie → Bearer
39
  let apiToken: string | undefined;
40
 
@@ -62,10 +69,10 @@ export async function POST(request: NextRequest) {
62
  const slideGenerationPrompt = buildSlidePrompt(prompt);
63
 
64
  try {
65
- console.log(`Attempting to use model: ${model}`);
66
 
67
  const response = await hf.textGeneration({
68
- model,
69
  inputs: slideGenerationPrompt,
70
  parameters: {
71
  max_new_tokens: 4000,
@@ -213,7 +220,7 @@ export async function POST(request: NextRequest) {
213
 
214
  if (modelError instanceof Error && modelError.message) {
215
  if (modelError.message.includes('not supported for task') || modelError.message.includes('conversational')) {
216
- errorMessage = `Model ${model} does not support text generation with Fireworks AI. Please choose a different model.`;
217
  statusCode = 400;
218
  } else if (modelError.message.includes('Rate limit') || modelError.message.includes('quota')) {
219
  errorMessage = 'Fireworks AI rate limit reached. Please try again in a moment.';
@@ -222,7 +229,7 @@ export async function POST(request: NextRequest) {
222
  errorMessage = 'Invalid Fireworks AI API token. Please check your API key.';
223
  statusCode = 401;
224
  } else if (modelError.message.includes('Model') && (modelError.message.includes('not found') || modelError.message.includes('not available'))) {
225
- errorMessage = `Model ${model} not found on Fireworks AI. Please verify the model name.`;
226
  statusCode = 404;
227
  } else {
228
  errorMessage = modelError.message;
 
12
  import { fetchImagesForSlides } from '@/lib/imageService';
13
  import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
14
  import type { GeneratedSlide } from '@/lib/slide-prompt';
15
+ import { LLAMA_PRESENTATION_MODEL, isAllowedPresentationModel } from '@/lib/ai-models';
16
 
17
  interface Slide {
18
  id: string;
 
29
  try {
30
  const { prompt, model } = await request.json();
31
 
32
+ if (!prompt) {
33
  return NextResponse.json(
34
+ { error: 'Prompt is required' },
35
  { status: 400 }
36
  );
37
  }
38
 
39
+ if (!isAllowedPresentationModel(model)) {
40
+ console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
41
+ }
42
+
43
+ const actualModel = LLAMA_PRESENTATION_MODEL;
44
+
45
  // Resolve HF token: header → cookie → Bearer
46
  let apiToken: string | undefined;
47
 
 
69
  const slideGenerationPrompt = buildSlidePrompt(prompt);
70
 
71
  try {
72
+ console.log(`Attempting to use model: ${actualModel}`);
73
 
74
  const response = await hf.textGeneration({
75
+ model: actualModel,
76
  inputs: slideGenerationPrompt,
77
  parameters: {
78
  max_new_tokens: 4000,
 
220
 
221
  if (modelError instanceof Error && modelError.message) {
222
  if (modelError.message.includes('not supported for task') || modelError.message.includes('conversational')) {
223
+ errorMessage = `Model ${actualModel} does not support text generation with Fireworks AI.`;
224
  statusCode = 400;
225
  } else if (modelError.message.includes('Rate limit') || modelError.message.includes('quota')) {
226
  errorMessage = 'Fireworks AI rate limit reached. Please try again in a moment.';
 
229
  errorMessage = 'Invalid Fireworks AI API token. Please check your API key.';
230
  statusCode = 401;
231
  } else if (modelError.message.includes('Model') && (modelError.message.includes('not found') || modelError.message.includes('not available'))) {
232
+ errorMessage = `Model ${actualModel} not found on Fireworks AI.`;
233
  statusCode = 404;
234
  } else {
235
  errorMessage = modelError.message;
app/api/presentations/generate/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
- import { GeminiClient } from '@/lib/gemini-client';
3
  import { HFClient, HFGenerationError } from '@/lib/hf-client';
 
4
  import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
5
 
6
  async function fetchImageForSlide(query: string): Promise<string | undefined> {
@@ -97,7 +97,9 @@ export async function POST(req: NextRequest) {
97
  const { prompt, model } = await req.json();
98
 
99
  if (!prompt) return NextResponse.json({ error: 'prompt required' }, { status: 400 });
100
- if (!model) return NextResponse.json({ error: 'model required' }, { status: 400 });
 
 
101
 
102
  // Resolve HF token: header → cookie → Bearer → env
103
  let hfToken = req.headers.get('x-hf-token');
@@ -113,151 +115,96 @@ export async function POST(req: NextRequest) {
113
 
114
  const systemPrompt = buildSlidePrompt(prompt);
115
 
116
- if (model.includes('gemini')) {
117
- const gemini = new GeminiClient();
 
 
 
118
 
119
- try {
120
- const response = await gemini.generateSlideContent(systemPrompt);
121
- const parsed = JSON.parse(response);
122
 
123
- if (parsed.presentationName) {
124
- presentationName = parsed.presentationName;
125
- }
126
 
127
- const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
128
- const total = slidesArray.length;
129
-
130
- slides = slidesArray.map((slide: any, index: number) => {
131
- const layout = normalizeLayout(slide.layout || '', index, total);
132
- return {
133
- id: `slide-${index + 1}`,
134
- title: slide.title || `Slide ${index + 1}`,
135
- subtitle: slide.subtitle || undefined,
136
- layout,
137
- content: normalizeSlideContent(slide.content, layout),
138
- columns: layout === 'three_columns'
139
- ? (normalizeColumns(slide.columns) || deriveColumnsFromContent(slide.content))
140
- : undefined,
141
- notes: slide.notes || '',
142
- imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
143
- };
144
- });
145
  } catch (error) {
146
- console.error('Gemini generation error:', error);
147
- slides = createFallbackSlides(prompt);
148
- }
149
- } else {
150
- if (!hfToken) {
151
- return NextResponse.json({
152
- error: 'HuggingFace authentication required for this model. Please connect your HuggingFace account.'
153
- }, { status: 401 });
154
- }
155
-
156
- const hf = new HFClient({ apiKey: hfToken, model });
157
 
158
- try {
159
- const fallbackModels = [
160
- 'deepseek-ai/DeepSeek-V3.1',
161
- 'meta-llama/Llama-4-Maverick-17B-128E-Instruct',
162
- ].filter((candidate) => candidate !== model);
163
-
164
- let response = '';
165
-
166
- try {
167
- response = await hf.generateSlideContent(systemPrompt, model);
168
- } catch (error) {
169
- const primaryMessage = error instanceof Error ? error.message : 'Unknown provider error';
170
- const shouldRetry = error instanceof HFGenerationError
171
- ? !error.status || error.status >= 500
172
- : true;
173
-
174
- if (!shouldRetry) throw error;
175
-
176
- let recovered = false;
177
- for (const fallbackModel of fallbackModels) {
178
- try {
179
- console.warn(`Primary HF model failed (${primaryMessage}). Retrying with ${fallbackModel}.`);
180
- response = await hf.generateSlideContent(systemPrompt, fallbackModel);
181
- recovered = true;
182
- break;
183
- } catch (fallbackError) {
184
- const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : 'Unknown provider error';
185
- console.warn(`HF fallback model ${fallbackModel} failed: ${fallbackMessage}`);
186
- }
187
- }
188
 
189
- if (!recovered) throw error;
190
- }
 
191
 
192
- console.log('Raw HF response (first 500 chars):', response.substring(0, 500));
193
 
194
- if (!response || response.trim().length === 0) {
195
- throw new Error('Empty response from model');
196
- }
197
 
198
- let cleanedJson = response.trim();
199
- cleanedJson = cleanedJson.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '');
200
 
201
- let parsed: any = null;
202
 
203
- // Try object parse first (new format)
204
- try {
205
- const firstBrace = cleanedJson.indexOf('{');
206
- const lastBrace = cleanedJson.lastIndexOf('}');
207
- if (firstBrace !== -1 && lastBrace !== -1) {
208
- parsed = JSON.parse(cleanedJson.substring(firstBrace, lastBrace + 1));
209
- }
210
- } catch { /* fall through */ }
211
-
212
- // Fallback to array parse
213
- if (!parsed) {
214
- let jsonMatch = cleanedJson.match(/\[\s*\{[\s\S]*?\}\s*\]/);
215
- if (!jsonMatch) {
216
- const firstBracket = cleanedJson.indexOf('[');
217
- const lastBracket = cleanedJson.lastIndexOf(']');
218
- if (firstBracket !== -1 && lastBracket !== -1 && lastBracket > firstBracket) {
219
- jsonMatch = [cleanedJson.substring(firstBracket, lastBracket + 1)];
220
- }
221
- }
222
- if (jsonMatch) {
223
- let jsonStr = jsonMatch[0];
224
- jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1');
225
- const arr = JSON.parse(jsonStr);
226
- parsed = { slides: Array.isArray(arr) ? arr : [arr] };
227
  }
228
  }
229
-
230
- if (!parsed) throw new Error('No JSON found in response');
231
-
232
- if (parsed.presentationName) {
233
- presentationName = parsed.presentationName;
234
  }
 
235
 
236
- const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
237
- const total = slidesArray.length;
238
-
239
- slides = slidesArray.map((slide: any, index: number) => {
240
- const layout = normalizeLayout(slide.layout || '', index, total);
241
- return {
242
- id: `slide-${index + 1}`,
243
- title: slide.title || `Slide ${index + 1}`,
244
- subtitle: slide.subtitle || undefined,
245
- layout,
246
- content: normalizeSlideContent(slide.content, layout),
247
- columns: layout === 'three_columns'
248
- ? (normalizeColumns(slide.columns) || deriveColumnsFromContent(slide.content))
249
- : undefined,
250
- notes: slide.notes || '',
251
- imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
252
- };
253
- });
254
-
255
- console.log(`Parsed ${slides.length} slides`);
256
- } catch (error) {
257
- const message = error instanceof Error ? error.message : 'Unknown provider error';
258
- console.warn(`HuggingFace generation failed, using fallback slides: ${message}`);
259
- slides = createFallbackSlides(prompt);
260
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  }
262
 
263
  if (slides.length === 0) slides = createFallbackSlides(prompt);
 
1
  import { NextRequest, NextResponse } from 'next/server';
 
2
  import { HFClient, HFGenerationError } from '@/lib/hf-client';
3
+ import { LLAMA_PRESENTATION_MODEL, isAllowedPresentationModel } from '@/lib/ai-models';
4
  import { buildSlidePrompt, normalizeLayout } from '@/lib/slide-prompt';
5
 
6
  async function fetchImageForSlide(query: string): Promise<string | undefined> {
 
97
  const { prompt, model } = await req.json();
98
 
99
  if (!prompt) return NextResponse.json({ error: 'prompt required' }, { status: 400 });
100
+ if (!isAllowedPresentationModel(model)) {
101
+ console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
102
+ }
103
 
104
  // Resolve HF token: header → cookie → Bearer → env
105
  let hfToken = req.headers.get('x-hf-token');
 
115
 
116
  const systemPrompt = buildSlidePrompt(prompt);
117
 
118
+ if (!hfToken) {
119
+ return NextResponse.json({
120
+ error: 'HuggingFace authentication required for this model. Please connect your HuggingFace account.'
121
+ }, { status: 401 });
122
+ }
123
 
124
+ const hf = new HFClient({ apiKey: hfToken, model: LLAMA_PRESENTATION_MODEL });
 
 
125
 
126
+ try {
127
+ let response = '';
 
128
 
129
+ try {
130
+ response = await hf.generateSlideContent(systemPrompt, LLAMA_PRESENTATION_MODEL);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  } catch (error) {
132
+ const shouldRetry = error instanceof HFGenerationError
133
+ ? !error.status || error.status >= 500
134
+ : true;
 
 
 
 
 
 
 
 
135
 
136
+ if (!shouldRetry) throw error;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
+ console.warn(`Primary Llama generation failed once. Retrying with ${LLAMA_PRESENTATION_MODEL}.`);
139
+ response = await hf.generateSlideContent(systemPrompt, LLAMA_PRESENTATION_MODEL);
140
+ }
141
 
142
+ console.log('Raw HF response (first 500 chars):', response.substring(0, 500));
143
 
144
+ if (!response || response.trim().length === 0) {
145
+ throw new Error('Empty response from model');
146
+ }
147
 
148
+ let cleanedJson = response.trim();
149
+ cleanedJson = cleanedJson.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '');
150
 
151
+ let parsed: any = null;
152
 
153
+ try {
154
+ const firstBrace = cleanedJson.indexOf('{');
155
+ const lastBrace = cleanedJson.lastIndexOf('}');
156
+ if (firstBrace !== -1 && lastBrace !== -1) {
157
+ parsed = JSON.parse(cleanedJson.substring(firstBrace, lastBrace + 1));
158
+ }
159
+ } catch { /* fall through */ }
160
+
161
+ if (!parsed) {
162
+ let jsonMatch = cleanedJson.match(/\[\s*\{[\s\S]*?\}\s*\]/);
163
+ if (!jsonMatch) {
164
+ const firstBracket = cleanedJson.indexOf('[');
165
+ const lastBracket = cleanedJson.lastIndexOf(']');
166
+ if (firstBracket !== -1 && lastBracket !== -1 && lastBracket > firstBracket) {
167
+ jsonMatch = [cleanedJson.substring(firstBracket, lastBracket + 1)];
 
 
 
 
 
 
 
 
 
168
  }
169
  }
170
+ if (jsonMatch) {
171
+ let jsonStr = jsonMatch[0];
172
+ jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1');
173
+ const arr = JSON.parse(jsonStr);
174
+ parsed = { slides: Array.isArray(arr) ? arr : [arr] };
175
  }
176
+ }
177
 
178
+ if (!parsed) throw new Error('No JSON found in response');
179
+
180
+ if (parsed.presentationName) {
181
+ presentationName = parsed.presentationName;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
183
+
184
+ const slidesArray = parsed.slides || (Array.isArray(parsed) ? parsed : []);
185
+ const total = slidesArray.length;
186
+
187
+ slides = slidesArray.map((slide: any, index: number) => {
188
+ const layout = normalizeLayout(slide.layout || '', index, total);
189
+ return {
190
+ id: `slide-${index + 1}`,
191
+ title: slide.title || `Slide ${index + 1}`,
192
+ subtitle: slide.subtitle || undefined,
193
+ layout,
194
+ content: normalizeSlideContent(slide.content, layout),
195
+ columns: layout === 'three_columns'
196
+ ? (normalizeColumns(slide.columns) || deriveColumnsFromContent(slide.content))
197
+ : undefined,
198
+ notes: slide.notes || '',
199
+ imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
200
+ };
201
+ });
202
+
203
+ console.log(`Parsed ${slides.length} slides`);
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : 'Unknown provider error';
206
+ console.warn(`HuggingFace generation failed, using fallback slides: ${message}`);
207
+ slides = createFallbackSlides(prompt);
208
  }
209
 
210
  if (slides.length === 0) slides = createFallbackSlides(prompt);
app/api/presentations/route.ts CHANGED
@@ -15,8 +15,9 @@
15
  * Output: { success: boolean, presentation: object, metadata: object }
16
  */
17
 
18
- import { NextRequest, NextResponse } from 'next/server';
19
- import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
 
20
 
21
  /**
22
  * POST handler for presentation generation
@@ -25,7 +26,7 @@ import { generatePresentation, type GenerationRequest, type Provider } from '@/l
25
  export async function POST(request: NextRequest) {
26
  try {
27
  const body = await request.json();
28
- const { prompt, model, provider }: { prompt: string; model?: string; provider?: Provider } = body;
29
 
30
  if (!prompt) {
31
  return NextResponse.json(
@@ -34,19 +35,21 @@ export async function POST(request: NextRequest) {
34
  );
35
  }
36
 
37
- // Determine provider based on model selection
38
- let finalProvider: Provider = provider || 'gemini';
39
- if (model && model.startsWith('deepseek-ai/') || model?.includes('llama') || model?.includes('qwen') || model?.includes('gpt-oss')) {
40
- finalProvider = 'hf';
41
- } else if (model === 'gemini-2.5-flash-lite' || model?.startsWith('gemini')) {
42
- finalProvider = 'gemini';
43
- }
44
-
45
- const generationRequest: GenerationRequest = {
46
- prompt,
47
- provider: finalProvider,
48
- model: model || (finalProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'deepseek-ai/DeepSeek-V3.1')
49
- };
 
 
50
 
51
  const presentation = await generatePresentation(generationRequest);
52
 
@@ -73,4 +76,4 @@ export async function POST(request: NextRequest) {
73
  { status: 500 }
74
  );
75
  }
76
- }
 
15
  * Output: { success: boolean, presentation: object, metadata: object }
16
  */
17
 
18
+ import { NextRequest, NextResponse } from 'next/server';
19
+ import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
20
+ import { LLAMA_PRESENTATION_MODEL, LLAMA_PRESENTATION_PROVIDER, isAllowedPresentationModel } from '@/lib/ai-models';
21
 
22
  /**
23
  * POST handler for presentation generation
 
26
  export async function POST(request: NextRequest) {
27
  try {
28
  const body = await request.json();
29
+ const { prompt, model, provider }: { prompt: string; model?: string; provider?: Provider } = body;
30
 
31
  if (!prompt) {
32
  return NextResponse.json(
 
35
  );
36
  }
37
 
38
+ if (provider && provider !== LLAMA_PRESENTATION_PROVIDER) {
39
+ console.warn(`Ignoring unsupported provider "${provider}" and forcing ${LLAMA_PRESENTATION_PROVIDER}.`);
40
+ }
41
+
42
+ if (!isAllowedPresentationModel(model)) {
43
+ console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
44
+ }
45
+
46
+ const finalProvider: Provider = LLAMA_PRESENTATION_PROVIDER;
47
+
48
+ const generationRequest: GenerationRequest = {
49
+ prompt,
50
+ provider: finalProvider,
51
+ model: LLAMA_PRESENTATION_MODEL,
52
+ };
53
 
54
  const presentation = await generatePresentation(generationRequest);
55
 
 
76
  { status: 500 }
77
  );
78
  }
79
+ }
app/api/upload-template/route.ts CHANGED
@@ -23,6 +23,7 @@
23
 
24
  import { NextRequest, NextResponse } from 'next/server';
25
  import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
 
26
 
27
  /**
28
  * POST /api/upload-template
@@ -73,15 +74,12 @@ export async function POST(request: NextRequest) {
73
  const arrayBuffer = await file.arrayBuffer();
74
  const base64File = Buffer.from(arrayBuffer).toString('base64');
75
 
76
- // Determine provider based on model selection
77
- // HF models are identified by organisation prefixes; everything else defaults to Gemini
78
- let finalProvider: Provider = 'gemini';
79
- if (model && (model.startsWith('deepseek-ai/') || model?.includes('llama') || model?.includes('qwen') || model?.includes('gpt-oss'))) {
80
- finalProvider = 'hf';
81
- } else if (model === 'gemini-2.5-flash-lite' || model?.startsWith('gemini')) {
82
- finalProvider = 'gemini';
83
  }
84
 
 
 
85
  // Build an enhanced prompt that instructs the AI to act as a template editor
86
  // rather than generating a presentation from scratch
87
  const enhancedPrompt = `
@@ -103,8 +101,7 @@ Generate a complete presentation that reflects both the original template's stre
103
  const generationRequest: GenerationRequest = {
104
  prompt: enhancedPrompt,
105
  provider: finalProvider,
106
- // Use the requested model or fall back to the provider's best default
107
- model: model || (finalProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'deepseek-ai/DeepSeek-V3.1'),
108
  // Add template context - in a real implementation, you'd parse the PowerPoint file
109
  templateContext: {
110
  fileName: file.name,
 
23
 
24
  import { NextRequest, NextResponse } from 'next/server';
25
  import { generatePresentation, type GenerationRequest, type Provider } from '@/lib/orchestrator';
26
+ import { LLAMA_PRESENTATION_MODEL, LLAMA_PRESENTATION_PROVIDER, isAllowedPresentationModel } from '@/lib/ai-models';
27
 
28
  /**
29
  * POST /api/upload-template
 
74
  const arrayBuffer = await file.arrayBuffer();
75
  const base64File = Buffer.from(arrayBuffer).toString('base64');
76
 
77
+ if (!isAllowedPresentationModel(model)) {
78
+ console.warn(`Ignoring unsupported presentation model "${model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
 
 
 
 
 
79
  }
80
 
81
+ const finalProvider: Provider = LLAMA_PRESENTATION_PROVIDER;
82
+
83
  // Build an enhanced prompt that instructs the AI to act as a template editor
84
  // rather than generating a presentation from scratch
85
  const enhancedPrompt = `
 
101
  const generationRequest: GenerationRequest = {
102
  prompt: enhancedPrompt,
103
  provider: finalProvider,
104
+ model: LLAMA_PRESENTATION_MODEL,
 
105
  // Add template context - in a real implementation, you'd parse the PowerPoint file
106
  templateContext: {
107
  fileName: file.name,
components/UnsplashImageSearch.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client';
2
 
3
- import React, { useState, useEffect } from 'react';
4
- import { Search, Download, X } from 'lucide-react';
5
 
6
  interface UnsplashImage {
7
  id: string;
@@ -25,8 +25,10 @@ interface UnsplashImageSearchProps {
25
  onClose: () => void;
26
  }
27
 
 
 
28
  export default function UnsplashImageSearch({ onImageSelect, onClose }: UnsplashImageSearchProps) {
29
- const [query, setQuery] = useState('');
30
  const [images, setImages] = useState<UnsplashImage[]>([]);
31
  const [isLoading, setIsLoading] = useState(false);
32
  const [error, setError] = useState<string | null>(null);
@@ -54,7 +56,7 @@ export default function UnsplashImageSearch({ onImageSelect, onClose }: Unsplash
54
  if (pageNum === 1) {
55
  setImages(newImages);
56
  } else {
57
- setImages(prev => [...prev, ...newImages]);
58
  }
59
 
60
  setHasMore(newImages.length === 20);
@@ -74,9 +76,14 @@ export default function UnsplashImageSearch({ onImageSelect, onClose }: Unsplash
74
  }
75
  };
76
 
 
 
 
 
 
77
  const loadMore = () => {
78
- if (!isLoading && hasMore && query) {
79
- searchImages(query, page + 1);
80
  }
81
  };
82
 
@@ -91,95 +98,218 @@ export default function UnsplashImageSearch({ onImageSelect, onClose }: Unsplash
91
  searchImages('presentation', 1);
92
  }, []);
93
 
 
 
 
94
  return (
95
- <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
96
- <div className="bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col">
97
- <div className="p-4 border-b border-gray-200 flex items-center justify-between">
98
- <h3 className="text-lg font-semibold text-gray-900">Search Unsplash Images</h3>
99
- <button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
100
- <X size={20} />
101
- </button>
102
- </div>
 
 
 
103
 
104
- <div className="p-4 border-b border-gray-200">
105
- <form onSubmit={handleSearch} className="flex gap-2">
106
- <div className="relative flex-1">
107
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={16} />
108
- <input
109
- type="text"
110
- value={query}
111
- onChange={(e) => setQuery(e.target.value)}
112
- placeholder="Search for images..."
113
- className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
114
- />
115
  </div>
 
116
  <button
117
- type="submit"
118
- disabled={isLoading || !query.trim()}
119
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
120
  >
121
- Search
122
  </button>
123
- </form>
124
- </div>
125
 
126
- <div className="flex-1 overflow-y-auto p-4">
127
- {error && (
128
- <div className="text-center py-8 text-red-600">{error}</div>
129
- )}
 
 
 
 
 
 
 
 
130
 
131
- <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
132
- {images.map((image) => (
133
- <div
134
- key={image.id}
135
- className="relative group cursor-pointer rounded-lg overflow-hidden bg-gray-100 aspect-video"
136
- onClick={() => handleImageSelect(image)}
137
  >
138
- <img
139
- src={image.urls.small}
140
- alt={image.alt_description || 'Unsplash image'}
141
- loading="lazy"
142
- referrerPolicy="no-referrer"
143
- className="w-full h-full object-cover transition-transform group-hover:scale-105"
144
- />
145
- <div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-opacity flex items-center justify-center">
146
- <Download className="text-white opacity-0 group-hover:opacity-100 transition-opacity" size={24} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
- <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2">
149
- <div className="text-white text-xs truncate">Photo by {image.user.name}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  </div>
 
 
 
 
151
  </div>
152
- ))}
153
- </div>
154
-
155
- {isLoading && (
156
- <div className="text-center py-8">
157
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
158
- <div className="text-gray-600 mt-2">Searching images...</div>
159
  </div>
160
  )}
161
 
162
- {hasMore && images.length > 0 && !isLoading && (
163
- <div className="text-center py-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  <button
165
  onClick={loadMore}
166
- className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
167
  >
168
  Load More
169
  </button>
170
- </div>
171
- )}
172
-
173
- {!hasMore && images.length > 0 && (
174
- <div className="text-center py-4 text-gray-500">No more images to load</div>
175
- )}
176
- </div>
177
-
178
- <div className="p-4 border-t border-gray-200 text-xs text-gray-500 text-center">
179
- Images provided by{' '}
180
- <a href="https://unsplash.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
181
- Unsplash
182
- </a>
183
  </div>
184
  </div>
185
  </div>
 
1
  'use client';
2
 
3
+ import React, { useEffect, useState } from 'react';
4
+ import { Download, ImageIcon, Loader2, Search, Sparkles, X } from 'lucide-react';
5
 
6
  interface UnsplashImage {
7
  id: string;
 
25
  onClose: () => void;
26
  }
27
 
28
+ const QUICK_SEARCHES = ['Presentation', 'Business', 'Technology', 'Teamwork', 'Marketing', 'Design'];
29
+
30
  export default function UnsplashImageSearch({ onImageSelect, onClose }: UnsplashImageSearchProps) {
31
+ const [query, setQuery] = useState('presentation');
32
  const [images, setImages] = useState<UnsplashImage[]>([]);
33
  const [isLoading, setIsLoading] = useState(false);
34
  const [error, setError] = useState<string | null>(null);
 
56
  if (pageNum === 1) {
57
  setImages(newImages);
58
  } else {
59
+ setImages((prev) => [...prev, ...newImages]);
60
  }
61
 
62
  setHasMore(newImages.length === 20);
 
76
  }
77
  };
78
 
79
+ const handleQuickSearch = (value: string) => {
80
+ setQuery(value);
81
+ searchImages(value, 1);
82
+ };
83
+
84
  const loadMore = () => {
85
+ if (!isLoading && hasMore && query.trim()) {
86
+ searchImages(query.trim(), page + 1);
87
  }
88
  };
89
 
 
98
  searchImages('presentation', 1);
99
  }, []);
100
 
101
+ const showInitialLoading = isLoading && images.length === 0;
102
+ const normalizedQuery = query.trim();
103
+
104
  return (
105
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-slate-950/60 p-4 backdrop-blur-md">
106
+ <div className="flex h-[min(88vh,820px)] w-full max-w-6xl flex-col overflow-hidden rounded-[28px] border border-white/70 bg-white/95 shadow-[0_32px_120px_rgba(15,23,42,0.32)]">
107
+ <div className="relative overflow-hidden border-b border-slate-200/80 bg-[radial-gradient(circle_at_top_left,_rgba(96,165,250,0.18),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(14,165,233,0.12),_transparent_28%),linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] px-6 pb-6 pt-5">
108
+ <div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-sky-300/70 to-transparent" />
109
+
110
+ <div className="flex items-start justify-between gap-4">
111
+ <div className="space-y-3">
112
+ <div className="inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50/80 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-700">
113
+ <Sparkles className="h-3.5 w-3.5" />
114
+ Curated Unsplash Search
115
+ </div>
116
 
117
+ <div>
118
+ <h3 className="text-[28px] font-semibold tracking-tight text-slate-950">Search Unsplash Images</h3>
119
+ <p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600">
120
+ Find polished, presentation-ready photography and drop it straight into your slide.
121
+ </p>
122
+ </div>
 
 
 
 
 
123
  </div>
124
+
125
  <button
126
+ onClick={onClose}
127
+ aria-label="Close Unsplash image search"
128
+ className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-sm transition hover:border-slate-300 hover:bg-white hover:text-slate-900"
129
  >
130
+ <X size={20} />
131
  </button>
132
+ </div>
 
133
 
134
+ <div className="mt-5 space-y-4">
135
+ <form onSubmit={handleSearch} className="flex flex-col gap-3 lg:flex-row">
136
+ <div className="relative flex-1">
137
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
138
+ <input
139
+ type="text"
140
+ value={query}
141
+ onChange={(e) => setQuery(e.target.value)}
142
+ placeholder="Search for images..."
143
+ className="h-14 w-full rounded-2xl border border-slate-200 bg-white pl-12 pr-4 text-[15px] text-slate-900 shadow-sm outline-none transition focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
144
+ />
145
+ </div>
146
 
147
+ <button
148
+ type="submit"
149
+ disabled={isLoading || !query.trim()}
150
+ className="inline-flex h-14 items-center justify-center gap-2 rounded-2xl bg-slate-950 px-6 text-sm font-semibold text-white shadow-lg shadow-slate-950/15 transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-300"
 
 
151
  >
152
+ {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
153
+ Search
154
+ </button>
155
+ </form>
156
+
157
+ <div className="flex flex-wrap gap-2">
158
+ {QUICK_SEARCHES.map((term) => {
159
+ const active = normalizedQuery.toLowerCase() === term.toLowerCase();
160
+
161
+ return (
162
+ <button
163
+ key={term}
164
+ type="button"
165
+ onClick={() => handleQuickSearch(term)}
166
+ className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${
167
+ active
168
+ ? 'border-slate-900 bg-slate-900 text-white shadow-sm'
169
+ : 'border-slate-200 bg-white/80 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900'
170
+ }`}
171
+ >
172
+ {term}
173
+ </button>
174
+ );
175
+ })}
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <div className="flex items-center justify-between gap-4 border-b border-slate-200/80 bg-white/90 px-6 py-3">
181
+ <div className="text-sm text-slate-600">
182
+ {isLoading
183
+ ? 'Refreshing results...'
184
+ : `${images.length} ${images.length === 1 ? 'image' : 'images'}${normalizedQuery ? ` for "${normalizedQuery}"` : ''}`}
185
+ </div>
186
+ <div className="text-xs font-medium uppercase tracking-[0.16em] text-slate-400">
187
+ Landscape photography
188
+ </div>
189
+ </div>
190
+
191
+ <div className="flex-1 overflow-y-auto bg-slate-50/80 px-6 py-6">
192
+ {error ? (
193
+ <div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
194
+ {error}
195
+ </div>
196
+ ) : null}
197
+
198
+ {showInitialLoading ? (
199
+ <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
200
+ {Array.from({ length: 8 }).map((_, index) => (
201
+ <div
202
+ key={`skeleton-${index}`}
203
+ className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm"
204
+ >
205
+ <div className="aspect-[4/3] animate-pulse bg-[linear-gradient(110deg,#e2e8f0,45%,#f8fafc,55%,#e2e8f0)] bg-[length:200%_100%]" />
206
+ <div className="space-y-2 p-4">
207
+ <div className="h-3 w-24 animate-pulse rounded-full bg-slate-200" />
208
+ <div className="h-4 w-40 animate-pulse rounded-full bg-slate-200" />
209
+ </div>
210
  </div>
211
+ ))}
212
+ </div>
213
+ ) : images.length > 0 ? (
214
+ <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
215
+ {images.map((image) => (
216
+ <button
217
+ type="button"
218
+ key={image.id}
219
+ onClick={() => handleImageSelect(image)}
220
+ aria-label={`Select image by ${image.user.name}`}
221
+ className="group overflow-hidden rounded-3xl border border-slate-200 bg-white text-left shadow-sm transition duration-300 hover:-translate-y-1 hover:shadow-[0_18px_40px_rgba(15,23,42,0.14)] focus:outline-none focus:ring-4 focus:ring-sky-100"
222
+ >
223
+ <div className="relative aspect-[4/3] overflow-hidden bg-slate-200">
224
+ {image.urls.small ? (
225
+ <img
226
+ src={image.urls.small}
227
+ alt={image.alt_description || 'Unsplash image'}
228
+ loading="lazy"
229
+ referrerPolicy="no-referrer"
230
+ className="h-full w-full object-cover transition duration-500 group-hover:scale-105"
231
+ />
232
+ ) : (
233
+ <div className="flex h-full items-center justify-center bg-slate-200 text-slate-500">
234
+ <ImageIcon className="h-8 w-8" />
235
+ </div>
236
+ )}
237
+
238
+ <div className="absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/5 to-transparent" />
239
+
240
+ <div className="absolute right-3 top-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/70 bg-white/85 text-slate-900 shadow-sm backdrop-blur-sm transition group-hover:scale-105">
241
+ <Download className="h-4 w-4" />
242
+ </div>
243
+
244
+ <div className="absolute inset-x-0 bottom-0 p-4">
245
+ <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">
246
+ Unsplash photo
247
+ </div>
248
+ <div className="truncate text-sm font-semibold text-white">
249
+ {image.alt_description || 'Use this image in your slide'}
250
+ </div>
251
+ <div className="mt-3 flex items-end justify-between gap-3">
252
+ <div className="min-w-0">
253
+ <div className="truncate text-xs font-medium text-white">Photo by {image.user.name}</div>
254
+ <div className="truncate text-[11px] text-white/65">@{image.user.username}</div>
255
+ </div>
256
+ <span className="rounded-full bg-white/15 px-3 py-1 text-[11px] font-medium text-white backdrop-blur-sm">
257
+ Use image
258
+ </span>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </button>
263
+ ))}
264
+ </div>
265
+ ) : (
266
+ <div className="flex min-h-[360px] items-center justify-center">
267
+ <div className="max-w-md rounded-3xl border border-slate-200 bg-white px-8 py-10 text-center shadow-sm">
268
+ <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-500">
269
+ <ImageIcon className="h-7 w-7" />
270
  </div>
271
+ <h4 className="mt-4 text-lg font-semibold text-slate-900">No images found</h4>
272
+ <p className="mt-2 text-sm leading-6 text-slate-600">
273
+ Try broader search terms like business, technology, teamwork, or product launch.
274
+ </p>
275
  </div>
 
 
 
 
 
 
 
276
  </div>
277
  )}
278
 
279
+ {isLoading && images.length > 0 ? (
280
+ <div className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-500">
281
+ <Loader2 className="h-4 w-4 animate-spin" />
282
+ Loading more images...
283
+ </div>
284
+ ) : null}
285
+ </div>
286
+
287
+ <div className="border-t border-slate-200/80 bg-white/90 px-6 py-4">
288
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
289
+ <p className="text-xs leading-5 text-slate-500">
290
+ Images provided by{' '}
291
+ <a
292
+ href="https://unsplash.com"
293
+ target="_blank"
294
+ rel="noopener noreferrer"
295
+ className="font-medium text-sky-600 hover:text-sky-700 hover:underline"
296
+ >
297
+ Unsplash
298
+ </a>
299
+ . Photographer attribution is preserved in the picker.
300
+ </p>
301
+
302
+ {hasMore && images.length > 0 && !isLoading ? (
303
  <button
304
  onClick={loadMore}
305
+ className="inline-flex items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
306
  >
307
  Load More
308
  </button>
309
+ ) : images.length > 0 ? (
310
+ <div className="text-xs font-medium uppercase tracking-[0.16em] text-slate-400">End of results</div>
311
+ ) : null}
312
+ </div>
 
 
 
 
 
 
 
 
 
313
  </div>
314
  </div>
315
  </div>
components/editor/GoogleSlidesEditor.tsx CHANGED
@@ -196,13 +196,9 @@ export default function GoogleSlidesEditor() {
196
  'Content-Type': 'application/json',
197
  };
198
 
199
- if (!generationModel.includes('gemini')) {
200
- // For HF models, get the API key from localStorage (set by HuggingFaceLogin)
201
- const hfApiKey = localStorage.getItem('hf_api_key');
202
- if (hfApiKey) {
203
- // Use x-hf-token header - this is the user's HF API key which will deduct their credits
204
- headers['x-hf-token'] = hfApiKey;
205
- }
206
  }
207
 
208
  const response = await fetch('/api/presentations/generate', {
 
196
  'Content-Type': 'application/json',
197
  };
198
 
199
+ const hfApiKey = localStorage.getItem('hf_api_key');
200
+ if (hfApiKey) {
201
+ headers['x-hf-token'] = hfApiKey;
 
 
 
 
202
  }
203
 
204
  const response = await fetch('/api/presentations/generate', {
components/slide-generator.tsx DELETED
@@ -1,441 +0,0 @@
1
- 'use client';
2
-
3
- import React, { useState, useRef } from 'react';
4
- import { DeepSeek, OpenAI } from '@lobehub/icons';
5
- import { HFClient, availableModels } from '@/lib/hf-client';
6
- import { GeminiClient } from '@/lib/gemini-client';
7
- import { generatePowerPoint } from '@/lib/ppt-generator';
8
-
9
- interface SlideContent {
10
- title?: string;
11
- subtitle?: string;
12
- authorName?: string;
13
- authorTitle?: string;
14
- date?: string;
15
- points?: Array<{ title: string; description: string }>;
16
- stats?: Array<{ number: string; label: string }>;
17
- highlightText?: string;
18
- takeaways?: string[];
19
- ctaTitle?: string;
20
- ctaText?: string;
21
- ctaButtonText?: string;
22
- contactInfo?: string;
23
- }
24
-
25
- interface Slide {
26
- id: string;
27
- type: 'title' | 'content' | 'conclusion';
28
- title: string;
29
- content: SlideContent;
30
- template: string;
31
- }
32
-
33
- interface SlideGeneratorProps {
34
- onSlidesGenerated?: (slides: Slide[]) => void;
35
- }
36
-
37
- const ChevronDownIcon = () => (
38
- <svg className="w-3.5 h-3.5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
39
- <path d="M6 9l6 6 6-6" />
40
- </svg>
41
- );
42
-
43
- const SendIcon = () => (
44
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
45
- <path d="M12 19V5" />
46
- <path d="M5 12l7-7 7 7" />
47
- </svg>
48
- );
49
-
50
- const LoadingIcon = () => (
51
- <svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
52
- <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
53
- </svg>
54
- );
55
-
56
- export default function SlideGenerator({ onSlidesGenerated }: SlideGeneratorProps) {
57
- const [prompt, setPrompt] = useState('');
58
- const [selectedModel, setSelectedModel] = useState(availableModels[0]);
59
- const [hfToken, setHfToken] = useState('');
60
- const [isGenerating, setIsGenerating] = useState(false);
61
- const [generatedSlides, setGeneratedSlides] = useState<Slide[]>([]);
62
- const [dropdownOpen, setDropdownOpen] = useState(false);
63
- const [currentSlideIndex, setCurrentSlideIndex] = useState(0);
64
- const [isDownloading, setIsDownloading] = useState(false);
65
- const dropdownRef = useRef<HTMLDivElement>(null);
66
-
67
- const modelIcons = {
68
- 'gemini-pro': () => (
69
- <div className="w-5 h-5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-bold">
70
- G
71
- </div>
72
- ),
73
- 'microsoft/DialoGPT-medium': () => <OpenAI.Avatar size={20} />,
74
- 'microsoft/DialoGPT-large': () => <OpenAI.Avatar size={20} />,
75
- 'deepseek-ai/DeepSeek-V3-0324': () => <DeepSeek.Color size={20} />,
76
- };
77
-
78
- const parseSlideContent = (content: string, slideNumber: number, totalSlides: number) => {
79
- const lines = content.split('\n').filter(line => line.trim());
80
-
81
- if (slideNumber === 1) {
82
- // Title slide
83
- return {
84
- type: 'title',
85
- title: lines[0] || 'Presentation Title',
86
- subtitle: lines[1] || 'Subtitle',
87
- authorName: 'Generated by AI',
88
- authorTitle: 'AI Assistant',
89
- date: new Date().toLocaleDateString(),
90
- };
91
- } else if (slideNumber === totalSlides) {
92
- // Conclusion slide
93
- return {
94
- type: 'conclusion',
95
- title: 'Conclusion',
96
- takeaways: lines.slice(0, 3).map(line => line.replace(/^[-•*]\s*/, '')),
97
- ctaTitle: 'Next Steps',
98
- ctaText: 'Ready to implement these insights?',
99
- ctaButtonText: 'Get Started',
100
- contactInfo: 'Contact us for more information',
101
- };
102
- } else {
103
- // Content slide
104
- const title = lines[0] || `Slide ${slideNumber}`;
105
- const points = lines.slice(1).map((line, index) => ({
106
- title: line.split(':')[0] || `Point ${index + 1}`,
107
- description: line.split(':')[1]?.trim() || line,
108
- }));
109
-
110
- return {
111
- type: 'content',
112
- title,
113
- subtitle: `Key insights and information`,
114
- points: points.slice(0, 4),
115
- stats: [
116
- { number: '95%', label: 'Success Rate' },
117
- { number: '24/7', label: 'Support' },
118
- ],
119
- highlightText: 'This information is crucial for understanding the topic.',
120
- };
121
- }
122
- };
123
-
124
- const generateSlides = async () => {
125
- // Check if token is required for paid models
126
- if (selectedModel.type === 'paid' && !hfToken.trim()) {
127
- alert('Please enter your Hugging Face token for paid models');
128
- return;
129
- }
130
-
131
- if (!prompt.trim()) {
132
- alert('Please enter a prompt for your presentation');
133
- return;
134
- }
135
-
136
- setIsGenerating(true);
137
- try {
138
- let sections: string[];
139
-
140
- if (selectedModel.provider === 'google') {
141
- // Use free Gemini model
142
- const geminiClient = new GeminiClient();
143
- sections = await geminiClient.generateOutline(prompt);
144
- } else {
145
- // Use paid Hugging Face models
146
- const client = new HFClient({ apiKey: hfToken, model: selectedModel.value });
147
- const outlinePrompt = `Create a presentation outline for: "${prompt}".
148
- Provide 3-5 main sections/slides. Return only the section titles, one per line.`;
149
- const outline = await client.generateSlideContent(outlinePrompt, selectedModel.value);
150
- sections = outline.split('\n').filter(line => line.trim());
151
- }
152
-
153
- const slides: Slide[] = [];
154
-
155
- // Generate each slide
156
- for (let i = 0; i < sections.length + 2; i++) { // +2 for title and conclusion
157
- let slideContent;
158
- let slideType: 'title' | 'content' | 'conclusion';
159
-
160
- if (selectedModel.provider === 'google') {
161
- // Use Gemini client
162
- const geminiClient = new GeminiClient();
163
-
164
- if (i === 0) {
165
- // Title slide
166
- slideType = 'title';
167
- const titlePrompt = `Create a compelling title and subtitle for a presentation about: "${prompt}"`;
168
- slideContent = await geminiClient.generateSlideContent(titlePrompt);
169
- } else if (i === sections.length + 1) {
170
- // Conclusion slide
171
- slideType = 'conclusion';
172
- const conclusionPrompt = `Create 3 key takeaways and a call to action for a presentation about: "${prompt}"`;
173
- slideContent = await geminiClient.generateSlideContent(conclusionPrompt);
174
- } else {
175
- // Content slide
176
- slideType = 'content';
177
- const contentPrompt = `Create detailed content for this section: "${sections[i - 1]}" in the context of: "${prompt}".
178
- Provide 3-4 key points with brief explanations.`;
179
- slideContent = await geminiClient.generateSlideContent(contentPrompt);
180
- }
181
- } else {
182
- // Use Hugging Face client
183
- const client = new HFClient({ apiKey: hfToken, model: selectedModel.value });
184
-
185
- if (i === 0) {
186
- // Title slide
187
- slideType = 'title';
188
- const titlePrompt = `Create a compelling title and subtitle for a presentation about: "${prompt}"`;
189
- slideContent = await client.generateSlideContent(titlePrompt, selectedModel.value);
190
- } else if (i === sections.length + 1) {
191
- // Conclusion slide
192
- slideType = 'conclusion';
193
- const conclusionPrompt = `Create 3 key takeaways and a call to action for a presentation about: "${prompt}"`;
194
- slideContent = await client.generateSlideContent(conclusionPrompt, selectedModel.value);
195
- } else {
196
- // Content slide
197
- slideType = 'content';
198
- const contentPrompt = `Create detailed content for this section: "${sections[i - 1]}" in the context of: "${prompt}".
199
- Provide 3-4 key points with brief explanations.`;
200
- slideContent = await client.generateSlideContent(contentPrompt, selectedModel.value);
201
- }
202
- }
203
-
204
- const parsedContent = parseSlideContent(slideContent, i + 1, sections.length + 2);
205
-
206
- slides.push({
207
- id: `slide-${i + 1}`,
208
- type: slideType,
209
- title: slideType === 'title' ? parsedContent.title :
210
- slideType === 'conclusion' ? 'Conclusion' :
211
- sections[i - 1] || `Slide ${i + 1}`,
212
- content: parsedContent,
213
- template: slideType === 'title' ? 'title-slide' :
214
- slideType === 'conclusion' ? 'conclusion-slide' : 'content-slide',
215
- });
216
- }
217
-
218
- setGeneratedSlides(slides);
219
- onSlidesGenerated?.(slides);
220
- } catch (error) {
221
- console.error('Error generating slides:', error);
222
- alert('Error generating slides. Please check your token and try again.');
223
- } finally {
224
- setIsGenerating(false);
225
- }
226
- };
227
-
228
- const nextSlide = () => {
229
- setCurrentSlideIndex((prev) => (prev + 1) % generatedSlides.length);
230
- };
231
-
232
- const prevSlide = () => {
233
- setCurrentSlideIndex((prev) => (prev - 1 + generatedSlides.length) % generatedSlides.length);
234
- };
235
-
236
- const handleDownload = async () => {
237
- if (generatedSlides.length === 0) return;
238
-
239
- setIsDownloading(true);
240
- try {
241
- await generatePowerPoint(generatedSlides);
242
- } catch (error) {
243
- console.error('Error generating PowerPoint:', error);
244
- alert('Error generating PowerPoint. Please try again.');
245
- } finally {
246
- setIsDownloading(false);
247
- }
248
- };
249
-
250
- const renderSlidePreview = (slide: Slide) => {
251
- return (
252
- <div className="w-full h-96 bg-white border border-gray-200 rounded-lg overflow-hidden shadow-lg">
253
- <div className="h-full flex items-center justify-center bg-gradient-to-br from-blue-500 to-purple-600 text-white p-6">
254
- <div className="text-center">
255
- <h2 className="text-2xl font-bold mb-4">{slide.title}</h2>
256
- {slide.type === 'title' && (
257
- <div>
258
- <p className="text-lg opacity-90">{slide.content.subtitle}</p>
259
- <p className="text-sm opacity-75 mt-4">{slide.content.authorName}</p>
260
- </div>
261
- )}
262
- {slide.type === 'content' && (
263
- <div className="text-left">
264
- {slide.content.points?.slice(0, 3).map((point, index: number) => (
265
- <div key={index} className="mb-3">
266
- <div className="font-semibold">{point.title}</div>
267
- <div className="text-sm opacity-90">{point.description}</div>
268
- </div>
269
- ))}
270
- </div>
271
- )}
272
- {slide.type === 'conclusion' && (
273
- <div>
274
- <div className="mb-4">
275
- {slide.content.takeaways?.map((takeaway: string, index: number) => (
276
- <div key={index} className="text-sm mb-2">• {takeaway}</div>
277
- ))}
278
- </div>
279
- <div className="bg-white bg-opacity-20 p-3 rounded">
280
- <div className="font-semibold">{slide.content.ctaTitle}</div>
281
- <div className="text-sm">{slide.content.ctaText}</div>
282
- </div>
283
- </div>
284
- )}
285
- </div>
286
- </div>
287
- </div>
288
- );
289
- };
290
-
291
- return (
292
- <div className="w-full max-w-6xl mx-auto p-6">
293
- {/* Input Section */}
294
- <div className="bg-white rounded-3xl border border-gray-200 shadow-lg p-6 mb-8">
295
- {selectedModel.type === 'paid' && (
296
- <div className="mb-4">
297
- <label className="block text-sm font-medium text-gray-700 mb-2">
298
- Hugging Face Token (Required for {selectedModel.name})
299
- </label>
300
- <input
301
- type="password"
302
- value={hfToken}
303
- onChange={(e) => setHfToken(e.target.value)}
304
- placeholder="Enter your Hugging Face token"
305
- className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
306
- />
307
- </div>
308
- )}
309
-
310
- {selectedModel.type === 'free' && (
311
- <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg">
312
- <div className="flex items-center gap-2 mb-2">
313
- <div className="w-2 h-2 bg-green-500 rounded-full"></div>
314
- <span className="text-sm font-medium text-green-800">
315
- {selectedModel.name} is free to use!
316
- </span>
317
- </div>
318
- <p className="text-xs text-green-700">
319
- Note: You may need a free Google AI Studio API key for full functionality.
320
- Get one at <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="underline">aistudio.google.com</a>
321
- </p>
322
- </div>
323
- )}
324
-
325
- <textarea
326
- rows={3}
327
- className="w-full bg-transparent border-0 outline-none text-lg text-slate-900 placeholder:text-gray-400 resize-none px-3 py-3 border border-gray-300 rounded-lg mb-4"
328
- placeholder="Describe your presentation topic..."
329
- value={prompt}
330
- onChange={(e) => setPrompt(e.target.value)}
331
- />
332
-
333
- <div className="flex items-center justify-between">
334
- <div className="relative" ref={dropdownRef}>
335
- <button
336
- onClick={() => setDropdownOpen(!dropdownOpen)}
337
- className="inline-flex items-center gap-2 rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm text-gray-800 hover:bg-gray-50 transition"
338
- >
339
- {React.createElement(modelIcons[selectedModel.value as keyof typeof modelIcons])}
340
- <span className="font-medium">{selectedModel.name}</span>
341
- <ChevronDownIcon />
342
- </button>
343
-
344
- {dropdownOpen && (
345
- <div className="absolute top-full mt-2 bg-white border border-gray-200 rounded-xl min-w-[220px] p-1.5 shadow-lg z-50">
346
- {availableModels.map((model) => (
347
- <button
348
- key={model.value}
349
- onClick={() => {
350
- setSelectedModel(model);
351
- setDropdownOpen(false);
352
- }}
353
- className="w-full text-left p-2.5 rounded-lg text-sm flex gap-3 items-center hover:bg-gray-100"
354
- >
355
- {React.createElement(modelIcons[model.value as keyof typeof modelIcons])}
356
- <div className="flex-grow">
357
- <div className="flex items-center gap-2">
358
- <span>{model.name}</span>
359
- {model.type === 'free' && (
360
- <span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
361
- FREE
362
- </span>
363
- )}
364
- {model.type === 'paid' && (
365
- <span className="text-xs bg-amber-100 text-amber-800 px-2 py-1 rounded-full">
366
- PAID
367
- </span>
368
- )}
369
- </div>
370
- </div>
371
- {selectedModel.value === model.value && <span className="text-green-500">✓</span>}
372
- </button>
373
- ))}
374
- </div>
375
- )}
376
- </div>
377
-
378
- <button
379
- onClick={generateSlides}
380
- disabled={isGenerating || !prompt.trim() || (selectedModel.type === 'paid' && !hfToken.trim())}
381
- className="inline-flex items-center gap-2 bg-blue-600 text-white px-6 py-2 rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
382
- >
383
- {isGenerating ? <LoadingIcon /> : <SendIcon />}
384
- {isGenerating ? 'Generating...' : 'Generate Slides'}
385
- </button>
386
- </div>
387
- </div>
388
-
389
- {/* Preview Section */}
390
- {generatedSlides.length > 0 && (
391
- <div className="bg-white rounded-3xl border border-gray-200 shadow-lg p-6">
392
- <div className="flex items-center justify-between mb-6">
393
- <h2 className="text-2xl font-bold text-gray-800">
394
- Slide Preview ({currentSlideIndex + 1} of {generatedSlides.length})
395
- </h2>
396
- <div className="flex gap-2">
397
- <button
398
- onClick={prevSlide}
399
- className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition"
400
- >
401
- Previous
402
- </button>
403
- <button
404
- onClick={nextSlide}
405
- className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition"
406
- >
407
- Next
408
- </button>
409
- </div>
410
- </div>
411
-
412
- {renderSlidePreview(generatedSlides[currentSlideIndex])}
413
-
414
- <div className="mt-6 flex justify-center">
415
- <button
416
- onClick={handleDownload}
417
- disabled={isDownloading}
418
- className="inline-flex items-center gap-2 bg-green-600 text-white px-8 py-3 rounded-xl hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
419
- >
420
- {isDownloading ? (
421
- <>
422
- <LoadingIcon />
423
- Generating...
424
- </>
425
- ) : (
426
- <>
427
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
428
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
429
- <polyline points="7,10 12,15 17,10" />
430
- <line x1="12" y1="15" x2="12" y2="3" />
431
- </svg>
432
- Download PowerPoint
433
- </>
434
- )}
435
- </button>
436
- </div>
437
- </div>
438
- )}
439
- </div>
440
- );
441
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/ai-models.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export const LLAMA_PRESENTATION_MODEL = 'meta-llama/Llama-3.3-70B-Instruct:together';
2
+ export const LLAMA_PRESENTATION_PROVIDER = 'hf' as const;
3
+
4
+ export function resolvePresentationModel(): string {
5
+ return LLAMA_PRESENTATION_MODEL;
6
+ }
7
+
8
+ export function isAllowedPresentationModel(model?: string | null): boolean {
9
+ return !model || model === LLAMA_PRESENTATION_MODEL;
10
+ }
lib/editable-pptx-export.ts CHANGED
@@ -96,16 +96,50 @@ function splitAgendaItem(text: string) {
96
  return { heading: heading || cleanText(text), description };
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  function formattingOffset(spec: SlideSpec, key: string) {
100
  const source = spec.formatting?.[key];
101
  const x = typeof source?.x === 'number' && Number.isFinite(source.x) ? source.x : 0;
102
  const y = typeof source?.y === 'number' && Number.isFinite(source.y) ? source.y : 0;
103
- return { x: pxToIn(x), y: pxToIn(y) };
 
 
 
 
 
 
 
 
104
  }
105
 
106
  function withOffset(spec: SlideSpec, key: string, rect: Rect): Rect {
107
  const offset = formattingOffset(spec, key);
108
- return { x: rect.x + offset.x, y: rect.y + offset.y, w: rect.w, h: rect.h };
 
 
 
 
 
109
  }
110
 
111
  function addTextBox(slide: PptxSlide, text: string, rect: Rect, options: Record<string, unknown> = {}) {
@@ -149,12 +183,16 @@ function getImageSizing(rect: Rect): Pick<ImageProps, 'x' | 'y' | 'w' | 'h' | 's
149
  };
150
  }
151
 
152
- async function sourceToImageProps(src: string | undefined, rect: Rect): Promise<ImageProps | null> {
 
 
 
 
153
  const cleanSrc = cleanText(src);
154
  if (!cleanSrc) return null;
155
 
156
  if (cleanSrc.startsWith('data:')) {
157
- return { data: cleanSrc, ...getImageSizing(rect) };
158
  }
159
 
160
  try {
@@ -167,18 +205,23 @@ async function sourceToImageProps(src: string | undefined, rect: Rect): Promise<
167
  reader.onerror = () => reject(reader.error || new Error('Failed to read image'));
168
  reader.readAsDataURL(blob);
169
  });
170
- if (dataUrl) return { data: dataUrl, ...getImageSizing(rect) };
171
  } catch {
172
  if (/^https?:\/\//i.test(cleanSrc)) {
173
- return { path: cleanSrc, ...getImageSizing(rect) };
174
  }
175
  }
176
 
177
  return null;
178
  }
179
 
180
- async function addImage(slide: PptxSlide, src: string | undefined, rect: Rect) {
181
- const imageProps = await sourceToImageProps(src, rect);
 
 
 
 
 
182
  if (imageProps) {
183
  slide.addImage(imageProps);
184
  return;
@@ -353,12 +396,19 @@ function addNeoShadowCard(
353
  slide: PptxSlide,
354
  rect: Rect,
355
  fillColor: string,
356
- options: { rotate?: number; shadowX?: number; shadowY?: number; lineWidth?: number } = {}
 
 
 
 
 
 
357
  ) {
358
  const {
359
  rotate = 0,
360
  shadowX = 0.12,
361
  shadowY = 0.12,
 
362
  lineWidth = 2.25,
363
  } = options;
364
 
@@ -368,8 +418,8 @@ function addNeoShadowCard(
368
  w: rect.w,
369
  h: rect.h,
370
  rotate,
371
- fill: { color: '000000' },
372
- line: { color: '000000', transparency: 100 },
373
  });
374
 
375
  slide.addShape('rect', {
@@ -432,7 +482,8 @@ function addNeoTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
432
 
433
  function addNeoAgenda(slide: PptxSlide, spec: SlideSpec) {
434
  addNeoBackground(slide);
435
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.55, y: 0.45, w: 4.2, h: 0.65 }), {
 
436
  fontFace: 'Arial Black',
437
  fontSize: 25,
438
  bold: true,
@@ -449,9 +500,13 @@ function addNeoAgenda(slide: PptxSlide, spec: SlideSpec) {
449
 
450
  (spec.items || []).slice(0, 5).forEach((item, index) => {
451
  const rect = withOffset(spec, `agenda-card-${index}`, { x: cards[index]?.x || 0.55, y: cards[index]?.y || 3.28, w: 2.45, h: 1.45 });
452
- addRect(slide, rect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
453
- addTextBox(slide, `${String(index + 1).padStart(2, '0')}`, { x: rect.x + 0.16, y: rect.y + 0.12, w: 0.4, h: 0.2 }, {
454
- fontFace: 'Arial',
 
 
 
 
455
  fontSize: 11,
456
  bold: true,
457
  color: 'A000A0',
@@ -461,16 +516,17 @@ function addNeoAgenda(slide: PptxSlide, spec: SlideSpec) {
461
  fontSize: 18,
462
  bold: true,
463
  color: '000000',
464
- valign: 'middle',
465
  });
466
  });
467
  }
468
 
469
  function addNeoTitleAndText(slide: PptxSlide, spec: SlideSpec) {
470
  addNeoBackground(slide);
 
471
  const titleRect = withOffset(spec, 'title-and-text-title', { x: 2.2, y: 1.0, w: 5.6, h: 0.85 });
472
- addRect(slide, titleRect, '00FFFF', '000000', { line: { color: '000000', width: 2.25 } });
473
- addTextBox(slide, cleanText(spec.title), titleRect, {
474
  fontFace: 'Arial Black',
475
  fontSize: 26,
476
  bold: true,
@@ -479,8 +535,8 @@ function addNeoTitleAndText(slide: PptxSlide, spec: SlideSpec) {
479
  });
480
 
481
  const bodyRect = withOffset(spec, 'title-and-text-body', { x: 1.25, y: 2.2, w: 7.5, h: 1.7 });
482
- addRect(slide, bodyRect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
483
- addTextBox(slide, bodyToParagraph(spec.body), bodyRect, {
484
  fontFace: 'Arial',
485
  fontSize: 18,
486
  color: '000000',
@@ -491,19 +547,30 @@ function addNeoTitleAndText(slide: PptxSlide, spec: SlideSpec) {
491
 
492
  function addNeoThreeColumns(slide: PptxSlide, spec: SlideSpec) {
493
  addNeoBackground(slide);
 
494
  const titleRect = withOffset(spec, 'columns-title', { x: 0.55, y: 0.45, w: 3.8, h: 0.75 });
495
- addRect(slide, titleRect, 'D9FF00', '000000', { line: { color: '000000', width: 2.25 } });
496
- addTextBox(slide, cleanText(spec.title), titleRect, {
497
  fontFace: 'Arial Black',
498
  fontSize: 22,
499
  bold: true,
500
  color: '000000',
501
  });
502
 
 
 
 
 
 
 
 
 
 
 
503
  const columnX = [0.55, 3.35, 6.15];
504
  (spec.columns || []).slice(0, 3).forEach((column, index) => {
505
  const rect = withOffset(spec, `column-card-${index}`, { x: columnX[index], y: 1.65, w: 2.55, h: 3.25 });
506
- addRect(slide, rect, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
507
  addRect(slide, { x: rect.x, y: rect.y, w: rect.w, h: 0.65 }, 'D9FF00', '000000', {
508
  line: { color: '000000', width: 0.75 },
509
  });
@@ -513,7 +580,7 @@ function addNeoThreeColumns(slide: PptxSlide, spec: SlideSpec) {
513
  bold: true,
514
  color: '000000',
515
  });
516
- addTextBox(slide, cleanText(column.heading), { x: rect.x + 0.18, y: rect.y + 0.85, w: rect.w - 0.36, h: 0.65 }, {
517
  fontFace: 'Arial Black',
518
  fontSize: 16,
519
  bold: true,
@@ -531,38 +598,55 @@ function addNeoThreeColumns(slide: PptxSlide, spec: SlideSpec) {
531
 
532
  async function addNeoImageAndText(slide: PptxSlide, spec: SlideSpec) {
533
  addNeoBackground(slide);
 
534
  const imageCard = withOffset(spec, 'image-card', { x: 0.6, y: 0.7, w: 4.15, h: 3.45 });
535
- addRect(slide, imageCard, 'FFD700', '000000', { line: { color: '000000', width: 2.25 } });
536
- await addImage(slide, spec.imageUrl, { x: imageCard.x + 0.18, y: imageCard.y + 0.18, w: imageCard.w - 0.36, h: 2.5 });
537
- addTextBox(slide, 'FIG 1. STRUCTURAL HONESTY', { x: imageCard.x + 0.18, y: imageCard.y + 2.82, w: imageCard.w - 0.36, h: 0.32 }, {
 
 
 
 
 
 
 
 
 
 
 
 
538
  fontFace: 'Arial Black',
539
  fontSize: 11,
540
  bold: true,
541
  color: '000000',
542
  align: 'center',
 
543
  });
544
 
545
  const textCard = withOffset(spec, 'image-text', { x: 5.3, y: 1.15, w: 4.05, h: 2.65 });
546
- addRect(slide, textCard, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 } });
547
- addTextBox(slide, cleanText(spec.title), { x: textCard.x + 0.2, y: textCard.y + 0.2, w: textCard.w - 0.4, h: 0.8 }, {
548
  fontFace: 'Arial Black',
549
  fontSize: 24,
550
  bold: true,
551
  color: '000000',
 
552
  });
553
  addTextBox(slide, bodyToParagraph(spec.body), { x: textCard.x + 0.2, y: textCard.y + 1.1, w: textCard.w - 0.4, h: 1.2 }, {
554
  fontFace: 'Arial',
555
  fontSize: 12,
556
  color: '000000',
557
  valign: 'top',
 
558
  });
559
  }
560
 
561
  function addNeoReferences(slide: PptxSlide, spec: SlideSpec) {
562
  addNeoBackground(slide);
 
563
  const titleRect = withOffset(spec, 'references-title', { x: 0.55, y: 0.45, w: 4.0, h: 0.65 });
564
- addRect(slide, titleRect, '000000', '000000');
565
- addTextBox(slide, cleanText(spec.title), titleRect, {
566
  fontFace: 'Arial Black',
567
  fontSize: 18,
568
  bold: true,
@@ -571,44 +655,95 @@ function addNeoReferences(slide: PptxSlide, spec: SlideSpec) {
571
 
572
  (spec.items || []).slice(0, 6).forEach((item, index) => {
573
  const row = withOffset(spec, `reference-item-${index}`, { x: 0.7, y: 1.45 + index * 0.62, w: 5.1, h: 0.45 });
574
- addTextBox(slide, `[${index + 1}]`, { x: row.x, y: row.y, w: 0.4, h: row.h }, {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  fontFace: 'Arial Black',
576
- fontSize: 12,
577
  color: 'A000A0',
578
  bold: true,
579
  });
580
- addTextBox(slide, cleanText(item.text), { x: row.x + 0.45, y: row.y, w: row.w - 0.45, h: row.h }, {
581
  fontFace: 'Arial',
582
- fontSize: 12,
583
  color: '000000',
 
 
584
  });
585
  });
586
 
587
  const noteRect = withOffset(spec, 'references-note', { x: 6.4, y: 1.55, w: 2.85, h: 2.0 });
588
- addRect(slide, noteRect, 'FFD700', '000000', { line: { color: '000000', width: 2.25 } });
589
- addTextBox(slide, 'ADD_NEW_SOURCE', { x: noteRect.x + 0.2, y: noteRect.y + 0.22, w: noteRect.w - 0.4, h: 0.3 }, {
 
 
 
 
 
 
 
 
 
 
590
  fontFace: 'Arial Black',
591
  fontSize: 14,
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  color: '000000',
593
  bold: true,
 
594
  });
595
  }
596
 
597
  function addNeoThankYou(slide: PptxSlide, spec: SlideSpec) {
598
  addNeoBackground(slide);
599
- const labelRect = { x: 1.95, y: 0.7, w: 1.3, h: 0.4 };
600
- addRect(slide, labelRect, 'A000A0', '000000', { line: { color: '000000', width: 2.25 } });
 
 
 
 
 
 
 
 
 
 
 
601
  addTextBox(slide, 'THE_END', labelRect, {
602
  fontFace: 'Arial Black',
603
  fontSize: 12,
604
  color: 'FFFFFF',
605
  bold: true,
606
  align: 'center',
 
607
  });
608
-
609
- const titleRect = withOffset(spec, 'thank-you-title', { x: 2.0, y: 1.05, w: 5.9, h: 2.2 });
610
- addRect(slide, titleRect, 'D9FF00', '000000', { line: { color: '000000', width: 2.25 } });
611
- addTextBox(slide, cleanText(spec.title), titleRect, {
612
  fontFace: 'Arial Black',
613
  fontSize: 30,
614
  bold: true,
@@ -617,7 +752,16 @@ function addNeoThankYou(slide: PptxSlide, spec: SlideSpec) {
617
  valign: 'middle',
618
  });
619
 
620
- addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.2, y: 4.05, w: 5.6, h: 0.42 }), {
 
 
 
 
 
 
 
 
 
621
  fontFace: 'Arial',
622
  fontSize: 13,
623
  bold: true,
@@ -837,16 +981,17 @@ function addGalerynThankYou(slide: PptxSlide, spec: SlideSpec) {
837
 
838
  function addNoisyTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
839
  addNoisyBackground(slide);
840
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 0.85, y: 1.1, w: 4.8, h: 0.8 }), {
841
  fontFace: 'Courier New',
842
- fontSize: 28,
843
  bold: true,
844
  color: 'FFFFFF',
845
  underline: true,
 
846
  });
847
- addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 0.95, y: 2.15, w: 5.7, h: 1.8 }), {
848
  fontFace: 'Courier New',
849
- fontSize: 16,
850
  color: 'FFFFFF',
851
  valign: 'top',
852
  });
@@ -854,9 +999,9 @@ function addNoisyTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
854
 
855
  function addNoisyAgenda(slide: PptxSlide, spec: SlideSpec) {
856
  addNoisyBackground(slide);
857
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.85, y: 0.72, w: 2.2, h: 0.6 }), {
858
  fontFace: 'Courier New',
859
- fontSize: 24,
860
  bold: true,
861
  color: 'FFFFFF',
862
  underline: true,
@@ -866,25 +1011,26 @@ function addNoisyAgenda(slide: PptxSlide, spec: SlideSpec) {
866
  const column = index % 3;
867
  const row = Math.floor(index / 3);
868
  const rect = withOffset(spec, `agenda-item-${index}`, {
869
- x: 1.0 + column * 2.8,
870
- y: 1.65 + row * 2.0,
871
- w: 2.0,
872
- h: 1.6,
873
  });
874
- addTextBox(slide, String(index + 1).padStart(2, '0'), { x: rect.x, y: rect.y, w: rect.w, h: 0.7 }, {
875
  fontFace: 'Courier New',
876
- fontSize: 34,
877
  bold: true,
878
  color: 'FFFFFF',
879
  align: 'center',
 
880
  });
881
- addRect(slide, { x: rect.x + 0.2, y: rect.y + 0.84, w: rect.w - 0.4, h: 0.08 }, 'FF7A59', 'FF7A59');
882
- addTextBox(slide, cleanText(item.text), { x: rect.x, y: rect.y + 1.0, w: rect.w, h: 0.42 }, {
883
  fontFace: 'Courier New',
884
- fontSize: 13,
885
  color: 'FFFFFF',
886
  align: 'center',
887
- valign: 'middle',
888
  });
889
  });
890
  }
@@ -919,16 +1065,16 @@ function addNoisyThreeColumns(slide: PptxSlide, spec: SlideSpec) {
919
 
920
  function addNoisyTitleAndText(slide: PptxSlide, spec: SlideSpec) {
921
  slide.background = { color: 'FFFFFF' };
922
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.85, y: 0.75, w: 4.5, h: 0.55 }), {
923
  fontFace: 'Courier New',
924
- fontSize: 22,
925
  bold: true,
926
  color: '547BEE',
927
  underline: true,
928
  });
929
- addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 0.9, y: 1.7, w: 8.1, h: 2.5 }), {
930
  fontFace: 'Courier New',
931
- fontSize: 16,
932
  color: '1F2937',
933
  valign: 'top',
934
  });
@@ -936,18 +1082,18 @@ function addNoisyTitleAndText(slide: PptxSlide, spec: SlideSpec) {
936
 
937
  async function addNoisyImageAndText(slide: PptxSlide, spec: SlideSpec) {
938
  addNoisyBackground(slide, 'F2725C');
939
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'image-title', { x: 0.8, y: 0.75, w: 4.0, h: 0.55 }), {
940
  fontFace: 'Courier New',
941
- fontSize: 22,
942
  bold: true,
943
  color: 'FFFFFF',
944
  underline: true,
945
  });
946
 
947
- await addImage(slide, spec.imageUrl, withOffset(spec, 'image-card', { x: 0.9, y: 1.6, w: 4.1, h: 2.7 }));
948
- addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-body', { x: 5.45, y: 1.65, w: 3.45, h: 2.25 }), {
949
  fontFace: 'Courier New',
950
- fontSize: 14,
951
  color: 'FFFFFF',
952
  valign: 'top',
953
  });
@@ -975,17 +1121,17 @@ function addNoisyReferences(slide: PptxSlide, spec: SlideSpec) {
975
 
976
  function addNoisyThankYou(slide: PptxSlide, spec: SlideSpec) {
977
  addNoisyBackground(slide);
978
- addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 2.1, y: 1.95, w: 5.8, h: 0.9 }), {
979
  fontFace: 'Courier New',
980
- fontSize: 30,
981
  bold: true,
982
  color: 'FFFFFF',
983
  align: 'center',
984
  valign: 'middle',
985
  });
986
- addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 2.8, y: 3.0, w: 4.4, h: 0.35 }), {
987
  fontFace: 'Courier New',
988
- fontSize: 15,
989
  color: 'FFFFFF',
990
  align: 'center',
991
  });
 
96
  return { heading: heading || cleanText(text), description };
97
  }
98
 
99
+ function parseReferenceItem(text: string, index: number) {
100
+ const parts = text
101
+ .split('||')
102
+ .map((part) => cleanText(part))
103
+ .filter(Boolean);
104
+
105
+ if (parts.length >= 2) {
106
+ return {
107
+ label: parts[0],
108
+ value: parts[1],
109
+ url: parts[2],
110
+ };
111
+ }
112
+
113
+ return {
114
+ label: `REF_${String(index + 1).padStart(2, '0')}`,
115
+ value: cleanText(text),
116
+ url: '',
117
+ };
118
+ }
119
+
120
  function formattingOffset(spec: SlideSpec, key: string) {
121
  const source = spec.formatting?.[key];
122
  const x = typeof source?.x === 'number' && Number.isFinite(source.x) ? source.x : 0;
123
  const y = typeof source?.y === 'number' && Number.isFinite(source.y) ? source.y : 0;
124
+ const width =
125
+ typeof source?.width === 'number' && Number.isFinite(source.width) && source.width > 0
126
+ ? pxToIn(source.width)
127
+ : undefined;
128
+ const height =
129
+ typeof source?.height === 'number' && Number.isFinite(source.height) && source.height > 0
130
+ ? pxToIn(source.height)
131
+ : undefined;
132
+ return { x: pxToIn(x), y: pxToIn(y), width, height };
133
  }
134
 
135
  function withOffset(spec: SlideSpec, key: string, rect: Rect): Rect {
136
  const offset = formattingOffset(spec, key);
137
+ return {
138
+ x: rect.x + offset.x,
139
+ y: rect.y + offset.y,
140
+ w: offset.width ?? rect.w,
141
+ h: offset.height ?? rect.h,
142
+ };
143
  }
144
 
145
  function addTextBox(slide: PptxSlide, text: string, rect: Rect, options: Record<string, unknown> = {}) {
 
183
  };
184
  }
185
 
186
+ async function sourceToImageProps(
187
+ src: string | undefined,
188
+ rect: Rect,
189
+ extra: Partial<ImageProps> = {}
190
+ ): Promise<ImageProps | null> {
191
  const cleanSrc = cleanText(src);
192
  if (!cleanSrc) return null;
193
 
194
  if (cleanSrc.startsWith('data:')) {
195
+ return { data: cleanSrc, ...getImageSizing(rect), ...extra };
196
  }
197
 
198
  try {
 
205
  reader.onerror = () => reject(reader.error || new Error('Failed to read image'));
206
  reader.readAsDataURL(blob);
207
  });
208
+ if (dataUrl) return { data: dataUrl, ...getImageSizing(rect), ...extra };
209
  } catch {
210
  if (/^https?:\/\//i.test(cleanSrc)) {
211
+ return { path: cleanSrc, ...getImageSizing(rect), ...extra };
212
  }
213
  }
214
 
215
  return null;
216
  }
217
 
218
+ async function addImage(
219
+ slide: PptxSlide,
220
+ src: string | undefined,
221
+ rect: Rect,
222
+ extra: Partial<ImageProps> = {}
223
+ ) {
224
+ const imageProps = await sourceToImageProps(src, rect, extra);
225
  if (imageProps) {
226
  slide.addImage(imageProps);
227
  return;
 
396
  slide: PptxSlide,
397
  rect: Rect,
398
  fillColor: string,
399
+ options: {
400
+ rotate?: number;
401
+ shadowX?: number;
402
+ shadowY?: number;
403
+ shadowColor?: string;
404
+ lineWidth?: number;
405
+ } = {}
406
  ) {
407
  const {
408
  rotate = 0,
409
  shadowX = 0.12,
410
  shadowY = 0.12,
411
+ shadowColor = '000000',
412
  lineWidth = 2.25,
413
  } = options;
414
 
 
418
  w: rect.w,
419
  h: rect.h,
420
  rotate,
421
+ fill: { color: normalizeColor(shadowColor) },
422
+ line: { color: normalizeColor(shadowColor), transparency: 100 },
423
  });
424
 
425
  slide.addShape('rect', {
 
482
 
483
  function addNeoAgenda(slide: PptxSlide, spec: SlideSpec) {
484
  addNeoBackground(slide);
485
+ addNeoDotPattern(slide);
486
+ addTextBox(slide, upperText(spec.title), withOffset(spec, 'agenda-title', { x: 0.55, y: 0.45, w: 4.2, h: 0.65 }), {
487
  fontFace: 'Arial Black',
488
  fontSize: 25,
489
  bold: true,
 
500
 
501
  (spec.items || []).slice(0, 5).forEach((item, index) => {
502
  const rect = withOffset(spec, `agenda-card-${index}`, { x: cards[index]?.x || 0.55, y: cards[index]?.y || 3.28, w: 2.45, h: 1.45 });
503
+ addNeoShadowCard(slide, rect, 'FFFFFF', {
504
+ shadowX: 0.1,
505
+ shadowY: 0.1,
506
+ shadowColor: 'A000A0',
507
+ });
508
+ addTextBox(slide, `${String(index + 1).padStart(2, '0')}/`, { x: rect.x + 0.16, y: rect.y + 0.12, w: 0.52, h: 0.2 }, {
509
+ fontFace: 'Arial Black',
510
  fontSize: 11,
511
  bold: true,
512
  color: 'A000A0',
 
516
  fontSize: 18,
517
  bold: true,
518
  color: '000000',
519
+ valign: 'top',
520
  });
521
  });
522
  }
523
 
524
  function addNeoTitleAndText(slide: PptxSlide, spec: SlideSpec) {
525
  addNeoBackground(slide);
526
+ addNeoDotPattern(slide);
527
  const titleRect = withOffset(spec, 'title-and-text-title', { x: 2.2, y: 1.0, w: 5.6, h: 0.85 });
528
+ addNeoShadowCard(slide, titleRect, '00FFFF', { shadowX: 0.1, shadowY: 0.1 });
529
+ addTextBox(slide, upperText(spec.title), titleRect, {
530
  fontFace: 'Arial Black',
531
  fontSize: 26,
532
  bold: true,
 
535
  });
536
 
537
  const bodyRect = withOffset(spec, 'title-and-text-body', { x: 1.25, y: 2.2, w: 7.5, h: 1.7 });
538
+ addNeoShadowCard(slide, bodyRect, 'FFFFFF', { shadowX: 0.1, shadowY: 0.1 });
539
+ addTextBox(slide, bodyToParagraph(spec.body), { x: bodyRect.x + 0.18, y: bodyRect.y + 0.18, w: bodyRect.w - 0.36, h: bodyRect.h - 0.36 }, {
540
  fontFace: 'Arial',
541
  fontSize: 18,
542
  color: '000000',
 
547
 
548
  function addNeoThreeColumns(slide: PptxSlide, spec: SlideSpec) {
549
  addNeoBackground(slide);
550
+ addNeoDotPattern(slide);
551
  const titleRect = withOffset(spec, 'columns-title', { x: 0.55, y: 0.45, w: 3.8, h: 0.75 });
552
+ addNeoShadowCard(slide, titleRect, 'D9FF00', { shadowX: 0.1, shadowY: 0.1 });
553
+ addTextBox(slide, upperText(spec.title), titleRect, {
554
  fontFace: 'Arial Black',
555
  fontSize: 22,
556
  bold: true,
557
  color: '000000',
558
  });
559
 
560
+ const decorRect = withOffset(spec, 'columns-decor', { x: 8.45, y: 0.42, w: 0.8, h: 0.8 });
561
+ slide.addShape('rect', {
562
+ x: decorRect.x,
563
+ y: decorRect.y,
564
+ w: decorRect.w,
565
+ h: decorRect.h,
566
+ fill: { color: 'FFFFFF', transparency: 75 },
567
+ line: { color: '000000', width: 2.25, transparency: 75 },
568
+ });
569
+
570
  const columnX = [0.55, 3.35, 6.15];
571
  (spec.columns || []).slice(0, 3).forEach((column, index) => {
572
  const rect = withOffset(spec, `column-card-${index}`, { x: columnX[index], y: 1.65, w: 2.55, h: 3.25 });
573
+ addNeoShadowCard(slide, rect, 'FFFFFF', { shadowX: 0.1, shadowY: 0.1 });
574
  addRect(slide, { x: rect.x, y: rect.y, w: rect.w, h: 0.65 }, 'D9FF00', '000000', {
575
  line: { color: '000000', width: 0.75 },
576
  });
 
580
  bold: true,
581
  color: '000000',
582
  });
583
+ addTextBox(slide, upperText(column.heading), { x: rect.x + 0.18, y: rect.y + 0.85, w: rect.w - 0.36, h: 0.65 }, {
584
  fontFace: 'Arial Black',
585
  fontSize: 16,
586
  bold: true,
 
598
 
599
  async function addNeoImageAndText(slide: PptxSlide, spec: SlideSpec) {
600
  addNeoBackground(slide);
601
+ addNeoDotPattern(slide);
602
  const imageCard = withOffset(spec, 'image-card', { x: 0.6, y: 0.7, w: 4.15, h: 3.45 });
603
+ addNeoShadowCard(slide, imageCard, 'FFD700', { rotate: 2, shadowX: 0.1, shadowY: 0.1 });
604
+ const imageFrame = {
605
+ x: imageCard.x + 0.18,
606
+ y: imageCard.y + 0.18,
607
+ w: imageCard.w - 0.36,
608
+ h: Math.max(2.35, imageCard.h - 0.95),
609
+ };
610
+ addRect(slide, imageFrame, 'FFFFFF', '000000', { line: { color: '000000', width: 2.25 }, rotate: 2 });
611
+ await addImage(slide, spec.imageUrl, {
612
+ x: imageFrame.x + 0.04,
613
+ y: imageFrame.y + 0.04,
614
+ w: imageFrame.w - 0.08,
615
+ h: imageFrame.h - 0.08,
616
+ }, { rotate: 2 });
617
+ addTextBox(slide, 'FIG 1. STRUCTURAL HONESTY', { x: imageCard.x + 0.18, y: imageCard.y + imageCard.h - 0.42, w: imageCard.w - 0.36, h: 0.22 }, {
618
  fontFace: 'Arial Black',
619
  fontSize: 11,
620
  bold: true,
621
  color: '000000',
622
  align: 'center',
623
+ rotate: 2,
624
  });
625
 
626
  const textCard = withOffset(spec, 'image-text', { x: 5.3, y: 1.15, w: 4.05, h: 2.65 });
627
+ addNeoShadowCard(slide, textCard, 'FFFFFF', { rotate: -1, shadowX: 0.1, shadowY: 0.1 });
628
+ addTextBox(slide, upperText(spec.title), { x: textCard.x + 0.2, y: textCard.y + 0.2, w: textCard.w - 0.4, h: 0.8 }, {
629
  fontFace: 'Arial Black',
630
  fontSize: 24,
631
  bold: true,
632
  color: '000000',
633
+ rotate: -1,
634
  });
635
  addTextBox(slide, bodyToParagraph(spec.body), { x: textCard.x + 0.2, y: textCard.y + 1.1, w: textCard.w - 0.4, h: 1.2 }, {
636
  fontFace: 'Arial',
637
  fontSize: 12,
638
  color: '000000',
639
  valign: 'top',
640
+ rotate: -1,
641
  });
642
  }
643
 
644
  function addNeoReferences(slide: PptxSlide, spec: SlideSpec) {
645
  addNeoBackground(slide);
646
+ addNeoDotPattern(slide);
647
  const titleRect = withOffset(spec, 'references-title', { x: 0.55, y: 0.45, w: 4.0, h: 0.65 });
648
+ addNeoShadowCard(slide, titleRect, '000000', { shadowX: 0.1, shadowY: 0.1 });
649
+ addTextBox(slide, upperText(spec.title), titleRect, {
650
  fontFace: 'Arial Black',
651
  fontSize: 18,
652
  bold: true,
 
655
 
656
  (spec.items || []).slice(0, 6).forEach((item, index) => {
657
  const row = withOffset(spec, `reference-item-${index}`, { x: 0.7, y: 1.45 + index * 0.62, w: 5.1, h: 0.45 });
658
+ const parsed = parseReferenceItem(item.text, index);
659
+ slide.addShape('rect', {
660
+ x: row.x,
661
+ y: row.y,
662
+ w: row.w,
663
+ h: row.h,
664
+ fill: { color: 'FFFFFF', transparency: 30 },
665
+ line: { color: 'FFFFFF', transparency: 100 },
666
+ });
667
+ slide.addShape('line', {
668
+ x: row.x,
669
+ y: row.y + row.h,
670
+ w: row.w,
671
+ h: 0,
672
+ line: { color: '000000', width: 2.25 },
673
+ });
674
+ addTextBox(slide, upperText(parsed.label), { x: row.x + 0.08, y: row.y + 0.05, w: 1.15, h: 0.14 }, {
675
  fontFace: 'Arial Black',
676
+ fontSize: 8.5,
677
  color: 'A000A0',
678
  bold: true,
679
  });
680
+ addTextBox(slide, parsed.value, { x: row.x + 0.08, y: row.y + 0.17, w: row.w - 0.16, h: row.h - 0.16 }, {
681
  fontFace: 'Arial',
682
+ fontSize: 12.5,
683
  color: '000000',
684
+ bold: true,
685
+ valign: 'top',
686
  });
687
  });
688
 
689
  const noteRect = withOffset(spec, 'references-note', { x: 6.4, y: 1.55, w: 2.85, h: 2.0 });
690
+ addNeoShadowCard(slide, noteRect, 'FFD700', { shadowX: 0.1, shadowY: 0.1 });
691
+ const noteLabelRect = { x: noteRect.x + 0.24, y: noteRect.y + 0.22, w: 1.55, h: 0.34 };
692
+ slide.addShape('rect', {
693
+ x: noteLabelRect.x,
694
+ y: noteLabelRect.y,
695
+ w: noteLabelRect.w,
696
+ h: noteLabelRect.h,
697
+ rotate: -2,
698
+ fill: { color: '000000' },
699
+ line: { color: '000000', width: 2.25 },
700
+ });
701
+ addTextBox(slide, 'RAW SOURCES', noteLabelRect, {
702
  fontFace: 'Arial Black',
703
  fontSize: 14,
704
+ color: 'FFFFFF',
705
+ bold: true,
706
+ align: 'center',
707
+ rotate: -2,
708
+ });
709
+ addTextBox(slide, 'Reference entries stay editable inline. Use label || title || url if you want a tagged source with a clickable link.', {
710
+ x: noteRect.x + 0.22,
711
+ y: noteRect.y + 0.72,
712
+ w: noteRect.w - 0.44,
713
+ h: noteRect.h - 0.92,
714
+ }, {
715
+ fontFace: 'Arial',
716
+ fontSize: 10.5,
717
  color: '000000',
718
  bold: true,
719
+ valign: 'top',
720
  });
721
  }
722
 
723
  function addNeoThankYou(slide: PptxSlide, spec: SlideSpec) {
724
  addNeoBackground(slide);
725
+ addNeoDotPattern(slide);
726
+ const titleRect = withOffset(spec, 'thank-you-title', { x: 2.0, y: 1.05, w: 5.9, h: 2.2 });
727
+ addNeoShadowCard(slide, titleRect, 'D9FF00', { shadowX: 0.1, shadowY: 0.1 });
728
+ const labelRect = { x: titleRect.x - 0.42, y: titleRect.y - 0.34, w: 1.25, h: 0.42 };
729
+ slide.addShape('rect', {
730
+ x: labelRect.x,
731
+ y: labelRect.y,
732
+ w: labelRect.w,
733
+ h: labelRect.h,
734
+ rotate: 12,
735
+ fill: { color: 'A000A0' },
736
+ line: { color: '000000', width: 2.25 },
737
+ });
738
  addTextBox(slide, 'THE_END', labelRect, {
739
  fontFace: 'Arial Black',
740
  fontSize: 12,
741
  color: 'FFFFFF',
742
  bold: true,
743
  align: 'center',
744
+ rotate: 12,
745
  });
746
+ addTextBox(slide, upperText(spec.title), titleRect, {
 
 
 
747
  fontFace: 'Arial Black',
748
  fontSize: 30,
749
  bold: true,
 
752
  valign: 'middle',
753
  });
754
 
755
+ const subtitleRect = withOffset(spec, 'thank-you-subtitle', { x: 2.2, y: 4.05, w: 5.6, h: 0.42 });
756
+ slide.addShape('rect', {
757
+ x: subtitleRect.x,
758
+ y: subtitleRect.y,
759
+ w: subtitleRect.w,
760
+ h: subtitleRect.h,
761
+ fill: { color: 'FFFFFF', transparency: 30 },
762
+ line: { color: 'FFFFFF', transparency: 100 },
763
+ });
764
+ addTextBox(slide, upperText(spec.subtitle), subtitleRect, {
765
  fontFace: 'Arial',
766
  fontSize: 13,
767
  bold: true,
 
981
 
982
  function addNoisyTitleSubtitle(slide: PptxSlide, spec: SlideSpec) {
983
  addNoisyBackground(slide);
984
+ addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title', { x: 0.68, y: 0.52, w: 5.0, h: 1.45 }), {
985
  fontFace: 'Courier New',
986
+ fontSize: 46,
987
  bold: true,
988
  color: 'FFFFFF',
989
  underline: true,
990
+ valign: 'top',
991
  });
992
+ addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 0.88, y: 3.18, w: 7.35, h: 1.05 }), {
993
  fontFace: 'Courier New',
994
+ fontSize: 19,
995
  color: 'FFFFFF',
996
  valign: 'top',
997
  });
 
999
 
1000
  function addNoisyAgenda(slide: PptxSlide, spec: SlideSpec) {
1001
  addNoisyBackground(slide);
1002
+ addTextBox(slide, cleanText(spec.title), withOffset(spec, 'agenda-title', { x: 0.68, y: 0.58, w: 2.35, h: 0.6 }), {
1003
  fontFace: 'Courier New',
1004
+ fontSize: 32,
1005
  bold: true,
1006
  color: 'FFFFFF',
1007
  underline: true,
 
1011
  const column = index % 3;
1012
  const row = Math.floor(index / 3);
1013
  const rect = withOffset(spec, `agenda-item-${index}`, {
1014
+ x: 0.55 + column * 3.06,
1015
+ y: 1.48 + row * 2.3,
1016
+ w: 2.38,
1017
+ h: 1.95,
1018
  });
1019
+ addTextBox(slide, String(index + 1).padStart(2, '0'), { x: rect.x, y: rect.y, w: rect.w, h: 0.95 }, {
1020
  fontFace: 'Courier New',
1021
+ fontSize: 78,
1022
  bold: true,
1023
  color: 'FFFFFF',
1024
  align: 'center',
1025
+ valign: 'middle',
1026
  });
1027
+ addRect(slide, { x: rect.x + 0.28, y: rect.y + 1.02, w: rect.w - 0.56, h: 0.1 }, 'FF7A59', 'FF7A59');
1028
+ addTextBox(slide, cleanText(item.text), { x: rect.x, y: rect.y + 1.28, w: rect.w, h: 0.7 }, {
1029
  fontFace: 'Courier New',
1030
+ fontSize: 23,
1031
  color: 'FFFFFF',
1032
  align: 'center',
1033
+ valign: 'top',
1034
  });
1035
  });
1036
  }
 
1065
 
1066
  function addNoisyTitleAndText(slide: PptxSlide, spec: SlideSpec) {
1067
  slide.background = { color: 'FFFFFF' };
1068
+ addTextBox(slide, cleanText(spec.title), withOffset(spec, 'title-and-text-title', { x: 0.95, y: 0.72, w: 5.2, h: 0.68 }), {
1069
  fontFace: 'Courier New',
1070
+ fontSize: 39,
1071
  bold: true,
1072
  color: '547BEE',
1073
  underline: true,
1074
  });
1075
+ addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x: 1.02, y: 1.72, w: 7.95, h: 2.25 }), {
1076
  fontFace: 'Courier New',
1077
+ fontSize: 24,
1078
  color: '1F2937',
1079
  valign: 'top',
1080
  });
 
1082
 
1083
  async function addNoisyImageAndText(slide: PptxSlide, spec: SlideSpec) {
1084
  addNoisyBackground(slide, 'F2725C');
1085
+ addTextBox(slide, cleanText(spec.title), withOffset(spec, 'image-title', { x: 0.72, y: 0.62, w: 4.2, h: 0.62 }), {
1086
  fontFace: 'Courier New',
1087
+ fontSize: 39,
1088
  bold: true,
1089
  color: 'FFFFFF',
1090
  underline: true,
1091
  });
1092
 
1093
+ await addImage(slide, spec.imageUrl, withOffset(spec, 'image-card', { x: 0.95, y: 1.48, w: 3.92, h: 2.48 }));
1094
+ addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-body', { x: 5.55, y: 1.82, w: 3.05, h: 2.0 }), {
1095
  fontFace: 'Courier New',
1096
+ fontSize: 22,
1097
  color: 'FFFFFF',
1098
  valign: 'top',
1099
  });
 
1121
 
1122
  function addNoisyThankYou(slide: PptxSlide, spec: SlideSpec) {
1123
  addNoisyBackground(slide);
1124
+ addTextBox(slide, cleanText(spec.title), withOffset(spec, 'thank-you-title', { x: 1.8, y: 1.72, w: 6.4, h: 0.95 }), {
1125
  fontFace: 'Courier New',
1126
+ fontSize: 63,
1127
  bold: true,
1128
  color: 'FFFFFF',
1129
  align: 'center',
1130
  valign: 'middle',
1131
  });
1132
+ addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'thank-you-subtitle', { x: 3.0, y: 3.02, w: 4.0, h: 0.42 }), {
1133
  fontFace: 'Courier New',
1134
+ fontSize: 27,
1135
  color: 'FFFFFF',
1136
  align: 'center',
1137
  });
lib/gemini-client.ts DELETED
@@ -1,150 +0,0 @@
1
- /**
2
- * Google Gemini Client
3
- *
4
- * Wraps the `@google/generative-ai` SDK and exposes three high-level helpers
5
- * used by the presentation generation pipeline:
6
- *
7
- * 1. `generateSlideContent` — runs the full JSON-generating prompt and returns
8
- * the raw model output (expected to be valid JSON).
9
- * 2. `generateSlideTitle` — derives a short title from existing slide content.
10
- * 3. `generateOutline` — produces a 3-5 item section outline for a topic.
11
- *
12
- * The client defaults to `gemini-2.0-flash-exp` which is the fastest model in
13
- * the Gemini 2 family and supports JSON-constrained output via `responseMimeType`.
14
- */
15
-
16
- import { GoogleGenerativeAI } from '@google/generative-ai';
17
-
18
- /** Optional configuration for {@link GeminiClient}. All fields are optional. */
19
- export interface GeminiClientConfig {
20
- /**
21
- * Google AI API key. When omitted, the client falls back to the
22
- * `NEXT_PUBLIC_GEMINI_API_KEY` environment variable and finally to a
23
- * hard-coded demo key (suitable for development only).
24
- */
25
- apiKey?: string;
26
- }
27
-
28
- /**
29
- * Thin wrapper around the Google Generative AI SDK scoped to Gemini models.
30
- *
31
- * @example
32
- * const gemini = new GeminiClient();
33
- * const json = await gemini.generateSlideContent(myPrompt);
34
- */
35
- export class GeminiClient {
36
- /** SDK root object that manages authentication and model lookup. */
37
- private genAI: GoogleGenerativeAI;
38
- /** Pre-configured model instance (gemini-2.0-flash-exp by default). */
39
- private model: ReturnType<GoogleGenerativeAI['getGenerativeModel']>;
40
-
41
- /**
42
- * Instantiates the client and resolves the API key from (in priority order):
43
- * 1. `config.apiKey` — explicitly passed by the caller
44
- * 2. `NEXT_PUBLIC_GEMINI_API_KEY` environment variable
45
- * 3. Hard-coded demo key (development fallback — should not reach production)
46
- *
47
- * @param config - Optional configuration; all fields have defaults.
48
- */
49
- constructor(config: GeminiClientConfig = {}) {
50
- // Resolve API key with fallback chain
51
- const apiKey = config.apiKey || process.env.NEXT_PUBLIC_GEMINI_API_KEY || 'AIzaSyCfv-5BPXsihzyp-dn4oe5SBBvF1MDd-sE';
52
-
53
- // Initialise the root SDK object with the resolved key
54
- this.genAI = new GoogleGenerativeAI(apiKey);
55
-
56
- // Obtain a model instance for gemini-2.0-flash-exp (fast + JSON output)
57
- this.model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' }); // Using Gemini 2.0 Flash
58
- }
59
-
60
- /**
61
- * Generates structured presentation slide content from a fully constructed
62
- * prompt (which already includes JSON schema instructions from the orchestrator).
63
- *
64
- * The response is constrained to `application/json` via `responseMimeType`,
65
- * which reduces post-processing and parsing errors.
66
- *
67
- * @param prompt - The complete generation prompt (system + user combined).
68
- * @returns Raw JSON string from the model; falls back to `"{}"` on empty responses.
69
- * @throws Error when the Gemini API call itself fails (network, quota, etc.).
70
- */
71
- async generateSlideContent(prompt: string): Promise<string> {
72
- try {
73
- // The prompt already contains the JSON schema instructions from orchestrator
74
- const result = await this.model.generateContent({
75
- contents: [{
76
- role: 'user', // Gemini uses a single user turn for generation
77
- parts: [{ text: prompt }]
78
- }],
79
- generationConfig: {
80
- temperature: 0.4, // Low temperature → consistent, structured output
81
- topK: 1, // Only consider the single most-likely token
82
- topP: 1, // No nucleus sampling cutoff
83
- maxOutputTokens: 4096, // Allow long presentations without truncation
84
- responseMimeType: 'application/json' // Force JSON-only output
85
- }
86
- });
87
-
88
- // Extract the text content from the response object
89
- const response = await result.response;
90
- // Return the text or an empty object string as a safe default
91
- return response.text() || '{}';
92
- } catch (error) {
93
- console.error('Error generating slide content with Gemini:', error);
94
- throw new Error('Failed to generate slide content');
95
- }
96
- }
97
-
98
- /**
99
- * Derives a concise, compelling title for a single slide given its body content.
100
- *
101
- * This method uses the more lenient `generateContent` overload (plain string)
102
- * since title generation does not require JSON output mode.
103
- *
104
- * @param content - The body text of the slide to title.
105
- * @returns A short title string, or `"Untitled Slide"` on failure.
106
- */
107
- async generateSlideTitle(content: string): Promise<string> {
108
- try {
109
- // Build a tightly-scoped prompt asking for just the title
110
- const prompt = `Generate a concise, compelling title for a presentation slide based on this content: "${content}". Return only the title, nothing else.`;
111
-
112
- const result = await this.model.generateContent(prompt);
113
- const response = await result.response;
114
- // Return the model's title or a safe fallback string
115
- return response.text() || 'Untitled Slide';
116
- } catch (error) {
117
- console.error('Error generating slide title with Gemini:', error);
118
- // Non-fatal — caller can proceed with a placeholder title
119
- return 'Untitled Slide';
120
- }
121
- }
122
-
123
- /**
124
- * Generates a high-level presentation outline (section headings) for a topic.
125
- *
126
- * The output is split on newlines and trimmed so the caller receives a clean
127
- * string array. At most 5 items are returned regardless of model output length.
128
- *
129
- * @param topic - The subject matter of the presentation (e.g. "Quantum Computing").
130
- * @returns An array of 3-5 section title strings.
131
- * Falls back to a generic four-section outline on error.
132
- */
133
- async generateOutline(topic: string): Promise<string[]> {
134
- try {
135
- // Ask for numbered section headings only — no bullets, no body text
136
- const prompt = `Create a presentation outline for: "${topic}". Provide 3-5 main sections/slides. Return only the section titles, one per line, without numbers or bullets.`;
137
-
138
- const result = await this.model.generateContent(prompt);
139
- const response = await result.response;
140
- const outline = response.text() || '';
141
-
142
- // Split by newline, discard blank lines, and cap at 5 sections
143
- return outline.split('\n').filter(line => line.trim()).slice(0, 5);
144
- } catch (error) {
145
- console.error('Error generating outline with Gemini:', error);
146
- // Return a minimal generic outline so callers never receive an empty array
147
- return ['Introduction', 'Main Topic', 'Key Points', 'Conclusion'];
148
- }
149
- }
150
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lib/hf-client.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { InferenceClient } from "@huggingface/inference";
 
2
 
3
  export interface HFClientConfig {
4
  apiKey: string;
@@ -33,20 +34,10 @@ export class HFGenerationError extends Error {
33
  }
34
 
35
  const conversationalModels: ConversationalModels = {
36
- "meta-llama/Llama-3.3-70B-Instruct:together": {
37
  "_id": "llama-33-70b-instruct",
38
  "status": "live",
39
- "providerId": "meta-llama/Llama-3.3-70B-Instruct:together"
40
- },
41
- "deepseek-ai/DeepSeek-V3.1": {
42
- "_id": "68a6d8bf70c5a6b780992d91",
43
- "status": "live",
44
- "providerId": "accounts/fireworks/models/deepseek-v3p1"
45
- },
46
- "meta-llama/Llama-4-Maverick-17B-128E-Instruct": {
47
- "_id": "67f3f65fc8b8113ab3f7daad",
48
- "status": "live",
49
- "providerId": "accounts/fireworks/models/llama4-maverick-instruct-basic"
50
  },
51
  };
52
 
@@ -56,7 +47,7 @@ export class HFClient {
56
 
57
  constructor(config: HFClientConfig) {
58
  this.client = new InferenceClient(config.apiKey);
59
- this.defaultModel = config.model || "meta-llama/Llama-3.3-70B-Instruct:together";
60
  }
61
 
62
  static getAvailableModels(): string[] {
@@ -162,5 +153,5 @@ export class HFClient {
162
  }
163
 
164
  export const availableModels = [
165
- { name: 'Llama-3.3-70B', value: 'meta-llama/Llama-3.3-70B-Instruct:together', type: 'paid', provider: 'together' },
166
  ];
 
1
  import { InferenceClient } from "@huggingface/inference";
2
+ import { LLAMA_PRESENTATION_MODEL } from "./ai-models";
3
 
4
  export interface HFClientConfig {
5
  apiKey: string;
 
34
  }
35
 
36
  const conversationalModels: ConversationalModels = {
37
+ [LLAMA_PRESENTATION_MODEL]: {
38
  "_id": "llama-33-70b-instruct",
39
  "status": "live",
40
+ "providerId": LLAMA_PRESENTATION_MODEL
 
 
 
 
 
 
 
 
 
 
41
  },
42
  };
43
 
 
47
 
48
  constructor(config: HFClientConfig) {
49
  this.client = new InferenceClient(config.apiKey);
50
+ this.defaultModel = config.model || LLAMA_PRESENTATION_MODEL;
51
  }
52
 
53
  static getAvailableModels(): string[] {
 
153
  }
154
 
155
  export const availableModels = [
156
+ { name: 'Llama-3.3-70B', value: LLAMA_PRESENTATION_MODEL, type: 'paid', provider: 'together' },
157
  ];
lib/orchestrator.ts CHANGED
@@ -2,53 +2,26 @@
2
  * Presentation Generation Orchestrator
3
  *
4
  * This module is the single entry-point for AI-powered presentation generation.
5
- * It accepts a {@link GenerationRequest} and routes it to the correct AI backend:
6
- *
7
- * - `"openai"` → OpenAI Chat Completions API (requires `OPENAI_API_KEY`)
8
- * - `"gemini"` → Google Gemini via {@link GeminiClient} (free tier available)
9
- * - `"hf"` → Hugging Face Inference (Fireworks AI router) via {@link HFClient}
10
  *
11
  * After content is generated the orchestrator enriches slides that carry image
12
  * keyword hints by fetching real photos from Unsplash.
13
  *
14
- * The function always returns a {@link PresentationJSON} if the model
15
  * produces unparseable output the result defaults to `{ theme: "dark", slides: [] }`.
16
  */
17
 
18
- import OpenAI from 'openai';
19
- import { GeminiClient } from './gemini-client';
20
  import { HFClient } from './hf-client';
21
  import { searchUnsplash } from './unsplash';
22
  import { buildSlidePrompt, normalizeLayout } from './slide-prompt';
 
23
 
24
- // ---------------------------------------------------------------------------
25
- // Public Types
26
- // ---------------------------------------------------------------------------
27
 
28
- /**
29
- * AI provider to use for generation.
30
- * - `"openai"` — OpenAI GPT models
31
- * - `"gemini"` — Google Gemini models
32
- * - `"hf"` — Hugging Face / Fireworks AI models
33
- */
34
- export type Provider = 'openai' | 'gemini' | 'hf';
35
-
36
- /** Parameters for a single generation call. */
37
  export interface GenerationRequest {
38
- /** Natural-language description of the desired presentation. */
39
  prompt: string;
40
- /** Which AI backend to use for generation. */
41
  provider: Provider;
42
- /**
43
- * Optional model identifier. When omitted a sensible default is chosen
44
- * per-provider (e.g. `gpt-4o-mini` for OpenAI, `gemini-2.0-flash-exp` for Gemini).
45
- */
46
  model?: string;
47
- /**
48
- * Optional uploaded PowerPoint template metadata passed from the
49
- * upload-template endpoint. Not used directly in generation but available
50
- * for future template-aware prompt engineering.
51
- */
52
  templateContext?: {
53
  fileName: string;
54
  fileSize: number;
@@ -57,17 +30,6 @@ export interface GenerationRequest {
57
  };
58
  }
59
 
60
- /**
61
- * A single slide within the generated presentation.
62
- *
63
- * Layout values use the new LayoutType system:
64
- * - `"title_subtitle"` → Opening title slide
65
- * - `"agenda"` → Table of contents
66
- * - `"title_and_text"` → Text-heavy content
67
- * - `"image_and_text"` → Content with image
68
- * - `"references"` → Citations
69
- * - `"thank_you"` → Closing slide
70
- */
71
  export interface SlideJSON {
72
  id: string;
73
  layout: string;
@@ -84,85 +46,37 @@ export interface SlideJSON {
84
  imageKeyword?: string;
85
  }
86
 
87
- /** Top-level presentation object returned to API consumers. */
88
  export interface PresentationJSON {
89
  theme: string;
90
  presentationName?: string;
91
  slides: SlideJSON[];
92
  }
93
 
94
- // ---------------------------------------------------------------------------
95
- // System Prompt — built dynamically per-request via buildSlidePrompt()
96
- // ---------------------------------------------------------------------------
97
-
98
- // ---------------------------------------------------------------------------
99
- // Main Entry Point
100
- // ---------------------------------------------------------------------------
101
-
102
- /**
103
- * Generates a complete presentation JSON object using the specified AI provider.
104
- *
105
- * The function:
106
- * 1. Constructs the full prompt for the chosen provider.
107
- * 2. Calls the provider's API and captures the raw text response.
108
- * 3. Attempts to `JSON.parse` the raw text; falls back to an empty presentation
109
- * if parsing fails.
110
- * 4. Iterates over slides that carry image keywords and replaces them with real
111
- * Unsplash photo URLs (up to 3 results per slide).
112
- *
113
- * @param req - Generation parameters including prompt, provider, and optional model.
114
- * @returns A fully-populated {@link PresentationJSON} with Unsplash images injected.
115
- * @throws Error when `provider === "hf"` and no HF API key is available.
116
- */
117
  export async function generatePresentation(req: GenerationRequest): Promise<PresentationJSON> {
118
- const userPrompt = req.prompt;
119
- const slidePrompt = buildSlidePrompt(userPrompt);
120
- let raw: string = '';
121
-
122
- if (req.provider === 'openai') {
123
- const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
124
-
125
- const chat = await client.chat.completions.create({
126
- model: req.model || 'gpt-4o-mini',
127
- temperature: 0.5,
128
- messages: [
129
- { role: 'system', content: slidePrompt },
130
- { role: 'user', content: `Create a professional presentation about: ${userPrompt}` },
131
- ],
132
- response_format: { type: 'json_object' },
133
- });
134
-
135
- raw = chat.choices[0]?.message?.content || '{}';
136
-
137
- } else if (req.provider === 'gemini') {
138
- const gemini = new GeminiClient();
139
- raw = await gemini.generateSlideContent(slidePrompt);
140
-
141
- } else if (req.provider === 'hf') {
142
- const hfApiKey = process.env.HF_API_KEY || process.env.NEXT_PUBLIC_HF_API_KEY;
143
-
144
- if (!hfApiKey) {
145
- throw new Error('HF Pro API key required for paid models');
146
- }
147
 
148
- const hf = new HFClient({
149
- apiKey: hfApiKey,
150
- model: req.model || 'deepseek-ai/DeepSeek-V3.1'
151
- });
152
 
153
- if (req.model && !HFClient.isModelAvailable(req.model)) {
154
- console.warn(`Model ${req.model} is not in the predefined list, using anyway`);
155
- }
156
 
157
- raw = await hf.generateSlideContent(slidePrompt, req.model || 'deepseek-ai/DeepSeek-V3.1');
 
158
  }
159
 
160
- // Parse the raw model output
 
 
 
 
161
  let parsed: PresentationJSON;
162
 
163
  try {
164
  const rawParsed = JSON.parse(raw);
165
- // Handle both new format { presentationName, slides } and old format { theme, slides }
166
  parsed = {
167
  theme: rawParsed.theme || 'professional',
168
  presentationName: rawParsed.presentationName,
@@ -180,11 +94,11 @@ export async function generatePresentation(req: GenerationRequest): Promise<Pres
180
  body: slide.body,
181
  columns: Array.isArray(slide.columns)
182
  ? slide.columns
183
- .filter((column: any) => column && typeof column === 'object')
184
- .map((column: any, columnIndex: number) => ({
185
- heading: String(column.heading || `Column ${columnIndex + 1}`),
186
- text: String(column.text || ''),
187
- }))
188
  : undefined,
189
  chart: slide.chart,
190
  images: slide.images,
@@ -194,17 +108,14 @@ export async function generatePresentation(req: GenerationRequest): Promise<Pres
194
  parsed = { theme: 'dark', slides: [] };
195
  }
196
 
197
- // Enrich slides with real Unsplash images
198
  for (const slide of parsed.slides) {
199
- // Try imageKeyword first (new format), then images array (old format)
200
  const keyword = slide.imageKeyword || (slide.images && slide.images.length ? slide.images[0] : '');
201
  if (keyword && slide.layout === 'image_and_text') {
202
  const results = await searchUnsplash(keyword, 3);
203
- slide.images = results.map(r => r.urls.regular);
204
  } else if (slide.images && slide.images.length) {
205
- const first = slide.images[0];
206
- const results = await searchUnsplash(first, 3);
207
- slide.images = results.map(r => r.urls.regular);
208
  }
209
  }
210
 
 
2
  * Presentation Generation Orchestrator
3
  *
4
  * This module is the single entry-point for AI-powered presentation generation.
5
+ * It uses the approved Hugging Face Llama 3.3 70B model for generation.
 
 
 
 
6
  *
7
  * After content is generated the orchestrator enriches slides that carry image
8
  * keyword hints by fetching real photos from Unsplash.
9
  *
10
+ * The function always returns a {@link PresentationJSON}. If the model
11
  * produces unparseable output the result defaults to `{ theme: "dark", slides: [] }`.
12
  */
13
 
 
 
14
  import { HFClient } from './hf-client';
15
  import { searchUnsplash } from './unsplash';
16
  import { buildSlidePrompt, normalizeLayout } from './slide-prompt';
17
+ import { LLAMA_PRESENTATION_MODEL } from './ai-models';
18
 
19
+ export type Provider = 'hf';
 
 
20
 
 
 
 
 
 
 
 
 
 
21
  export interface GenerationRequest {
 
22
  prompt: string;
 
23
  provider: Provider;
 
 
 
 
24
  model?: string;
 
 
 
 
 
25
  templateContext?: {
26
  fileName: string;
27
  fileSize: number;
 
30
  };
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
 
33
  export interface SlideJSON {
34
  id: string;
35
  layout: string;
 
46
  imageKeyword?: string;
47
  }
48
 
 
49
  export interface PresentationJSON {
50
  theme: string;
51
  presentationName?: string;
52
  slides: SlideJSON[];
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export async function generatePresentation(req: GenerationRequest): Promise<PresentationJSON> {
56
+ const slidePrompt = buildSlidePrompt(req.prompt);
57
+ const hfApiKey = process.env.HF_API_KEY || process.env.NEXT_PUBLIC_HF_API_KEY;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ if (!hfApiKey) {
60
+ throw new Error('HF Pro API key required for Llama 3.3 70B generation');
61
+ }
 
62
 
63
+ if (req.provider && req.provider !== 'hf') {
64
+ console.warn(`Ignoring unsupported provider "${req.provider}" and forcing hf.`);
65
+ }
66
 
67
+ if (req.model && req.model !== LLAMA_PRESENTATION_MODEL) {
68
+ console.warn(`Ignoring unsupported presentation model "${req.model}" and forcing ${LLAMA_PRESENTATION_MODEL}.`);
69
  }
70
 
71
+ const raw = await new HFClient({
72
+ apiKey: hfApiKey,
73
+ model: LLAMA_PRESENTATION_MODEL,
74
+ }).generateSlideContent(slidePrompt, LLAMA_PRESENTATION_MODEL);
75
+
76
  let parsed: PresentationJSON;
77
 
78
  try {
79
  const rawParsed = JSON.parse(raw);
 
80
  parsed = {
81
  theme: rawParsed.theme || 'professional',
82
  presentationName: rawParsed.presentationName,
 
94
  body: slide.body,
95
  columns: Array.isArray(slide.columns)
96
  ? slide.columns
97
+ .filter((column: any) => column && typeof column === 'object')
98
+ .map((column: any, columnIndex: number) => ({
99
+ heading: String(column.heading || `Column ${columnIndex + 1}`),
100
+ text: String(column.text || ''),
101
+ }))
102
  : undefined,
103
  chart: slide.chart,
104
  images: slide.images,
 
108
  parsed = { theme: 'dark', slides: [] };
109
  }
110
 
 
111
  for (const slide of parsed.slides) {
 
112
  const keyword = slide.imageKeyword || (slide.images && slide.images.length ? slide.images[0] : '');
113
  if (keyword && slide.layout === 'image_and_text') {
114
  const results = await searchUnsplash(keyword, 3);
115
+ slide.images = results.map((result) => result.urls.regular);
116
  } else if (slide.images && slide.images.length) {
117
+ const results = await searchUnsplash(slide.images[0], 3);
118
+ slide.images = results.map((result) => result.urls.regular);
 
119
  }
120
  }
121
 
lib/slide-prompt.ts CHANGED
@@ -29,14 +29,20 @@ export interface GeneratedSlide {
29
  * @param topic - The user's presentation topic
30
  */
31
  export function buildSlidePrompt(topic: string): string {
32
- return `You are an expert presentation designer. Create a professional, engaging presentation about: "${topic}"
 
 
 
33
 
34
  INSTRUCTIONS:
35
- 1. Generate 8-10 slides with detailed, topic-specific content
36
- 2. The first slide title should relate to the topic (NOT "Reuben AI")
37
- 3. All content must be specific and avoid generic placeholders
38
- 4. For "title_and_text" and "image_and_text" slides, write one short paragraph of 30-55 words
39
- 5. Generate relevant imageKeyword for Unsplash (2-4 descriptive terms)
 
 
 
40
 
41
  OUTPUT FORMAT - Return ONLY valid JSON matching this exact structure:
42
  {
@@ -110,6 +116,8 @@ CONTENT RULES:
110
  - "title_and_text" and "image_and_text" should use a single string paragraph, not an array
111
  - "three_columns" should use a "columns" array with exactly 3 objects, each containing "heading" and "text"
112
  - Keep paragraphs compact and presentation-friendly, not essay-length
 
 
113
  - Do NOT add a trailing references slide before "thank_you" unless the user explicitly asks for one
114
 
115
  VALIDATION:
 
29
  * @param topic - The user's presentation topic
30
  */
31
  export function buildSlidePrompt(topic: string): string {
32
+ return `You are an expert presentation strategist and slide designer. Your job is to read the user's prompt, infer the likely audience and intent, and turn it into a polished presentation with specific, useful, presentation-ready content.
33
+
34
+ USER PROMPT:
35
+ "${topic}"
36
 
37
  INSTRUCTIONS:
38
+ 1. Understand the user prompt before writing anything. Infer the topic, purpose, audience, and tone from the prompt itself.
39
+ 2. Generate 8-10 slides with detailed, topic-specific content that feels intentional and professionally written.
40
+ 3. The first slide title should clearly relate to the topic (NOT "Reuben AI").
41
+ 4. All content must be specific, concrete, and useful. Do not use generic filler, vague business jargon, or placeholder phrasing.
42
+ 5. For "title_and_text" and "image_and_text" slides, write one short paragraph of 30-55 words that sounds natural when spoken in a presentation.
43
+ 6. Generate relevant imageKeyword for Unsplash using 2-4 descriptive terms.
44
+ 7. If the user prompt implies a place, industry, audience, or timeframe, reflect that in the content.
45
+ 8. Prefer strong slide titles, clean logic, and audience-friendly wording over flashy but empty language.
46
 
47
  OUTPUT FORMAT - Return ONLY valid JSON matching this exact structure:
48
  {
 
116
  - "title_and_text" and "image_and_text" should use a single string paragraph, not an array
117
  - "three_columns" should use a "columns" array with exactly 3 objects, each containing "heading" and "text"
118
  - Keep paragraphs compact and presentation-friendly, not essay-length
119
+ - Make every slide feel like it belongs to the same presentation, not a random collection of facts
120
+ - Write like a strong human presentation writer, not a template generator
121
  - Do NOT add a trailing references slide before "thank_you" unless the user explicitly asks for one
122
 
123
  VALIDATION: