Spaces:
Running
Running
PPTX export fixed
Browse files- app/api/ai-edit-text/route.ts +13 -78
- app/api/generate-slides/route.ts +13 -6
- app/api/presentations/generate/route.ts +78 -131
- app/api/presentations/route.ts +20 -17
- app/api/upload-template/route.ts +6 -9
- components/UnsplashImageSearch.tsx +205 -75
- components/editor/GoogleSlidesEditor.tsx +3 -7
- components/slide-generator.tsx +0 -441
- lib/ai-models.ts +10 -0
- lib/editable-pptx-export.ts +219 -73
- lib/gemini-client.ts +0 -150
- lib/hf-client.ts +5 -14
- lib/orchestrator.ts +27 -116
- lib/slide-prompt.ts +14 -6
app/api/ai-edit-text/route.ts
CHANGED
|
@@ -1,47 +1,17 @@
|
|
| 1 |
/**
|
| 2 |
* AI Text Editing Endpoint
|
| 3 |
*
|
| 4 |
-
*
|
| 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 |
-
|
| 128 |
-
|
| 129 |
}
|
|
|
|
| 130 |
|
| 131 |
-
if (hfToken) {
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 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 |
-
|
| 145 |
-
|
| 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 |
-
|
| 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
|
| 32 |
return NextResponse.json(
|
| 33 |
-
{ error: 'Prompt
|
| 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: ${
|
| 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 ${
|
| 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 ${
|
| 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)
|
|
|
|
|
|
|
| 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 (
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
const response = await gemini.generateSlideContent(systemPrompt);
|
| 121 |
-
const parsed = JSON.parse(response);
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
}
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 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 |
-
|
| 147 |
-
|
| 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 |
-
|
| 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 |
-
|
| 190 |
-
|
|
|
|
| 191 |
|
| 192 |
-
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
| 200 |
|
| 201 |
-
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
if (!
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
}
|
|
|
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 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 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
const
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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 |
-
|
| 77 |
-
|
| 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 |
-
|
| 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, {
|
| 4 |
-
import { Search,
|
| 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
|
| 96 |
-
<div className="
|
| 97 |
-
<div className="
|
| 98 |
-
<
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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 |
-
|
| 118 |
-
|
| 119 |
-
className="
|
| 120 |
>
|
| 121 |
-
|
| 122 |
</button>
|
| 123 |
-
</
|
| 124 |
-
</div>
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
className="relative group cursor-pointer rounded-lg overflow-hidden bg-gray-100 aspect-video"
|
| 136 |
-
onClick={() => handleImageSelect(image)}
|
| 137 |
>
|
| 138 |
-
<
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
</div>
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 163 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
<button
|
| 165 |
onClick={loadMore}
|
| 166 |
-
className="px-4 py-2
|
| 167 |
>
|
| 168 |
Load More
|
| 169 |
</button>
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 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 |
-
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
function withOffset(spec: SlideSpec, key: string, rect: Rect): Rect {
|
| 107 |
const offset = formattingOffset(spec, key);
|
| 108 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 372 |
-
line: { color:
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 453 |
-
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: '
|
| 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 |
-
|
| 473 |
-
addTextBox(slide,
|
| 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 |
-
|
| 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 |
-
|
| 496 |
-
addTextBox(slide,
|
| 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 |
-
|
| 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,
|
| 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 |
-
|
| 536 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 547 |
-
addTextBox(slide,
|
| 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 |
-
|
| 565 |
-
addTextBox(slide,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
fontFace: 'Arial Black',
|
| 576 |
-
fontSize:
|
| 577 |
color: 'A000A0',
|
| 578 |
bold: true,
|
| 579 |
});
|
| 580 |
-
addTextBox(slide,
|
| 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 |
-
|
| 589 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 841 |
fontFace: 'Courier New',
|
| 842 |
-
fontSize:
|
| 843 |
bold: true,
|
| 844 |
color: 'FFFFFF',
|
| 845 |
underline: true,
|
|
|
|
| 846 |
});
|
| 847 |
-
addTextBox(slide, cleanText(spec.subtitle), withOffset(spec, 'subtitle', { x: 0.
|
| 848 |
fontFace: 'Courier New',
|
| 849 |
-
fontSize:
|
| 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.
|
| 858 |
fontFace: 'Courier New',
|
| 859 |
-
fontSize:
|
| 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:
|
| 870 |
-
y: 1.
|
| 871 |
-
w: 2.
|
| 872 |
-
h: 1.
|
| 873 |
});
|
| 874 |
-
addTextBox(slide, String(index + 1).padStart(2, '0'), { x: rect.x, y: rect.y, w: rect.w, h: 0.
|
| 875 |
fontFace: 'Courier New',
|
| 876 |
-
fontSize:
|
| 877 |
bold: true,
|
| 878 |
color: 'FFFFFF',
|
| 879 |
align: 'center',
|
|
|
|
| 880 |
});
|
| 881 |
-
addRect(slide, { x: rect.x + 0.
|
| 882 |
-
addTextBox(slide, cleanText(item.text), { x: rect.x, y: rect.y + 1.
|
| 883 |
fontFace: 'Courier New',
|
| 884 |
-
fontSize:
|
| 885 |
color: 'FFFFFF',
|
| 886 |
align: 'center',
|
| 887 |
-
valign: '
|
| 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.
|
| 923 |
fontFace: 'Courier New',
|
| 924 |
-
fontSize:
|
| 925 |
bold: true,
|
| 926 |
color: '547BEE',
|
| 927 |
underline: true,
|
| 928 |
});
|
| 929 |
-
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'title-and-text-body-0', { x:
|
| 930 |
fontFace: 'Courier New',
|
| 931 |
-
fontSize:
|
| 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.
|
| 940 |
fontFace: 'Courier New',
|
| 941 |
-
fontSize:
|
| 942 |
bold: true,
|
| 943 |
color: 'FFFFFF',
|
| 944 |
underline: true,
|
| 945 |
});
|
| 946 |
|
| 947 |
-
await addImage(slide, spec.imageUrl, withOffset(spec, 'image-card', { x: 0.
|
| 948 |
-
addTextBox(slide, bodyToParagraph(spec.body), withOffset(spec, 'image-body', { x: 5.
|
| 949 |
fontFace: 'Courier New',
|
| 950 |
-
fontSize:
|
| 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:
|
| 979 |
fontFace: 'Courier New',
|
| 980 |
-
fontSize:
|
| 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:
|
| 987 |
fontFace: 'Courier New',
|
| 988 |
-
fontSize:
|
| 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 |
-
|
| 37 |
"_id": "llama-33-70b-instruct",
|
| 38 |
"status": "live",
|
| 39 |
-
"providerId":
|
| 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 ||
|
| 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:
|
| 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
|
| 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}
|
| 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
|
| 119 |
-
const
|
| 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 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
});
|
| 152 |
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
|
| 157 |
-
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 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(
|
| 204 |
} else if (slide.images && slide.images.length) {
|
| 205 |
-
const
|
| 206 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
INSTRUCTIONS:
|
| 35 |
-
1.
|
| 36 |
-
2.
|
| 37 |
-
3.
|
| 38 |
-
4.
|
| 39 |
-
5.
|
|
|
|
|
|
|
|
|
|
| 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:
|