ajout plan byok, plus correction d'affichage et navigation
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,3 +30,5 @@ dist-ssr
|
||||
.next/dev/trace
|
||||
.next/dev/cache/turbopack/23c46498/CURRENT
|
||||
.next/dev/cache/turbopack/23c46498/LOG
|
||||
.next/dev/cache/turbopack/23c46498/CURRENT
|
||||
.next/dev/cache/turbopack/23c46498/LOG
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "plumeia",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.38.0",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.1",
|
||||
@@ -17,6 +18,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"openai": "^6.25.0",
|
||||
"pg": "^8.19.0",
|
||||
"prisma": "^7.4.1",
|
||||
"react": "19.2.3",
|
||||
@@ -52,6 +54,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.78.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
|
||||
"integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.41.0",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
|
||||
@@ -273,6 +295,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
@@ -6008,6 +6039,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -6962,6 +7006,27 @@
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.25.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz",
|
||||
"integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"openai": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ws": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@@ -8472,6 +8537,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
@@ -9056,7 +9127,7 @@
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.38.0",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.1",
|
||||
@@ -21,6 +22,7 @@
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"openai": "^6.25.0",
|
||||
"pg": "^8.19.0",
|
||||
"prisma": "^7.4.1",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -40,6 +40,10 @@ model User {
|
||||
planId String? @default("free")
|
||||
subscriptionPlan Plan? @relation(fields: [planId], references: [id])
|
||||
|
||||
// Bring Your Own Key
|
||||
customApiProvider String?
|
||||
customApiKey String?
|
||||
|
||||
aiActionsUsed Int @default(0)
|
||||
dailyWordGoal Int @default(500)
|
||||
writingStreak Int @default(0)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env.local' });
|
||||
import getDB from '../src/lib/prisma';
|
||||
config();
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const prisma = getDB();
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
const pool = new Pool({ connectionString });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding plans...');
|
||||
@@ -41,6 +46,17 @@ async function main() {
|
||||
features: ['250 actions IA / mois', 'Accès Gemini 3 Pro', 'Bible du monde avancée', 'Outils de révision avancés'],
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
id: 'byok',
|
||||
name: 'byok',
|
||||
displayName: 'Clé Perso (BYOK)',
|
||||
price: 4.99,
|
||||
description: 'Utilisez vos propres clés API (ChatGPT, Claude, Gemini).',
|
||||
maxProjects: -1,
|
||||
maxAiActions: -1,
|
||||
features: ['Tokens illimités via votre clé', 'Mode Bring Your Own Key', 'Choix du modèle IA', 'Projets illimités'],
|
||||
isPopular: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const plan of plans) {
|
||||
|
||||
@@ -54,6 +54,8 @@ export async function GET() {
|
||||
dailyWordGoal: user.dailyWordGoal,
|
||||
writingStreak: user.writingStreak,
|
||||
lastWriteDate: user.lastWriteDate,
|
||||
customApiProvider: user.customApiProvider,
|
||||
customApiKey: user.customApiKey,
|
||||
createdAt: user.createdAt,
|
||||
totalWords,
|
||||
});
|
||||
@@ -78,6 +80,12 @@ export async function PUT(request: NextRequest) {
|
||||
if (body.dailyWordGoal !== undefined) data.dailyWordGoal = body.dailyWordGoal;
|
||||
if (body.writingStreak !== undefined) data.writingStreak = body.writingStreak;
|
||||
if (body.lastWriteDate !== undefined) data.lastWriteDate = body.lastWriteDate ? new Date(body.lastWriteDate) : null;
|
||||
if (body.customApiProvider !== undefined) data.customApiProvider = body.customApiProvider;
|
||||
if (body.customApiKey !== undefined) data.customApiKey = body.customApiKey;
|
||||
if (body.planId !== undefined) {
|
||||
data.planId = body.planId;
|
||||
data.plan = body.planId; // legacy sync
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
|
||||
@@ -1,15 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import Checkout from '@/components/Checkout';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
|
||||
export default function CheckoutPage() {
|
||||
function CheckoutContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const planId = searchParams.get('plan') || 'pro';
|
||||
|
||||
const [plan, setPlan] = useState<{ id: string, displayName: string, price: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/plans', { cache: 'no-store' })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const found = data.find((p: any) => p.id === planId);
|
||||
if (found) {
|
||||
setPlan(found);
|
||||
} else {
|
||||
setPlan(data.find((p: any) => p.id === 'pro') || { id: 'pro', displayName: 'Auteur Pro', price: 12.00 });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
setPlan({ id: 'pro', displayName: 'Auteur Pro', price: 12.00 });
|
||||
});
|
||||
}, [planId]);
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (plan) {
|
||||
try {
|
||||
await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ planId: plan.id })
|
||||
});
|
||||
// Force a full reload to update user context in providers
|
||||
window.location.href = '/dashboard';
|
||||
} catch (err) {
|
||||
console.error('Failed to update plan', err);
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
if (!plan) return <div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">Chargement...</div>;
|
||||
|
||||
return (
|
||||
<Checkout
|
||||
onComplete={() => router.push('/dashboard')}
|
||||
plan={plan}
|
||||
onComplete={handleComplete}
|
||||
onCancel={() => router.push('/pricing')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#eef2ff] flex items-center justify-center p-8">Chargement...</div>}>
|
||||
<CheckoutContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PricingPage() {
|
||||
isLoading={isLoading}
|
||||
currentPlan={user?.subscription.plan || 'free'}
|
||||
onBack={() => router.push(user ? '/dashboard' : '/')}
|
||||
onSelectPlan={() => router.push(user ? '/checkout' : '/login')}
|
||||
onSelectPlan={(id) => router.push(user ? `/checkout?plan=${id}` : '/login')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import { CreditCard, Shield, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useLanguage } from '@/providers/LanguageProvider';
|
||||
|
||||
interface CheckoutProps {
|
||||
plan: { id: string; displayName: string; price: number };
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const Checkout: React.FC<CheckoutProps> = ({ onComplete, onCancel }) => {
|
||||
const Checkout: React.FC<CheckoutProps> = ({ plan, onComplete, onCancel }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useLanguage();
|
||||
|
||||
@@ -28,10 +29,10 @@ const Checkout: React.FC<CheckoutProps> = ({ onComplete, onCancel }) => {
|
||||
<div className="w-full md:w-1/3 bg-slate-900 text-white p-8">
|
||||
<h3 className="text-xl font-bold mb-8 flex items-center gap-2"><Lock size={18} className="text-blue-400" /> {t('checkout.order')}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.pro_author')}</span><span>12.00€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.vat')}</span><span>2.40€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{plan.displayName}</span><span>{plan.price.toFixed(2)}€</span></div>
|
||||
<div className="flex justify-between text-sm"><span>{t('checkout.vat')} (20%)</span><span>{(plan.price * 0.2).toFixed(2)}€</span></div>
|
||||
<div className="h-px bg-slate-800 my-4" />
|
||||
<div className="flex justify-between text-xl font-black"><span>{t('checkout.total')}</span><span className="text-blue-400">14.40€</span></div>
|
||||
<div className="flex justify-between text-xl font-black"><span>{t('checkout.total')}</span><span className="text-blue-400">{(plan.price * 1.2).toFixed(2)}€</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-8 md:p-12">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserProfile, UserPreferences } from '@/lib/types';
|
||||
import { User, Settings, Globe, Shield, Bell, Save, Camera, Target, Flame, Layout } from 'lucide-react';
|
||||
import { useLanguage } from '@/providers/LanguageProvider';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface UserProfileSettingsProps {
|
||||
user: UserProfile;
|
||||
@@ -36,9 +37,13 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
panel: '#24283b',
|
||||
text: '#c0caf5',
|
||||
accent: '#7aa2f7'
|
||||
}
|
||||
},
|
||||
customApiProvider: user.customApiProvider || 'openai',
|
||||
customApiKey: user.customApiKey || ''
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// Handle Live Preview of Theme Changes
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -114,7 +119,9 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
theme: formData.theme,
|
||||
dailyWordGoal: formData.dailyWordGoal,
|
||||
customColors: formData.customColors
|
||||
}
|
||||
},
|
||||
customApiProvider: formData.customApiProvider as any,
|
||||
customApiKey: formData.customApiKey
|
||||
});
|
||||
|
||||
localStorage.setItem('plumeia_theme', formData.theme);
|
||||
@@ -296,7 +303,12 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
<h4 className="font-bold text-blue-900">{t('profile.plan_title')} {(user.subscription.planDetails?.displayName || user.subscription.plan).toUpperCase()}</h4>
|
||||
<p className="text-xs text-blue-700">{t('profile.active_sub')}</p>
|
||||
</div>
|
||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-blue-700 shadow-md shadow-blue-200">{t('profile.manage')}</button>
|
||||
<button
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-xs font-bold hover:bg-blue-700 shadow-md shadow-blue-200"
|
||||
>
|
||||
{t('profile.manage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -309,7 +321,58 @@ const UserProfileSettings: React.FC<UserProfileSettingsProps> = ({ user, onUpdat
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
{/* BYOK Settings */}
|
||||
{user.subscription.plan === 'byok' && (
|
||||
<div className={`p-6 rounded-xl border mt-8 ${isCustom ? 'border-theme-border bg-theme-panel' : isDark ? 'border-slate-700 bg-slate-800/50' : isSepia ? 'border-[#dfcdae] bg-[#f4ecd8]/50' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<h4 className={`font-bold mb-4 flex items-center gap-2 ${themeTextHeading}`}>
|
||||
<Globe size={18} className="text-blue-500" /> Vos clés API (BYOK)
|
||||
</h4>
|
||||
<p className={`text-xs mb-6 ${themeTextMuted}`}>
|
||||
Vous avez l'abonnement "Clé Perso". Vous pouvez utiliser l'IA en illimité en renseignant votre propre clé API.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs font-black uppercase tracking-widest ${themeTextMuted}`}>Fournisseur IA</label>
|
||||
<select
|
||||
value={formData.customApiProvider}
|
||||
onChange={(e) => setFormData({ ...formData, customApiProvider: e.target.value as any })}
|
||||
className={`w-full p-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 ${themeInputBg}`}
|
||||
>
|
||||
<option value="openai">OpenAI (ChatGPT)</option>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className={`text-xs font-black uppercase tracking-widest ${themeTextMuted}`}>Clé API ({formData.customApiProvider === 'openai' ? 'sk-proj-...' : formData.customApiProvider === 'anthropic' ? 'sk-ant-...' : 'AIza...'})</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.customApiKey}
|
||||
onChange={(e) => setFormData({ ...formData, customApiKey: e.target.value })}
|
||||
placeholder="Collez votre clé secrète ici..."
|
||||
className={`w-full p-3 rounded-xl outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm ${themeInputBg}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.subscription.plan !== 'byok' && user.subscription.plan !== 'master' && (
|
||||
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-xl mt-8">
|
||||
<h4 className="font-bold text-indigo-900 text-sm mb-1">Passer à l'abonnement "Clé Perso (BYOK)" ?</h4>
|
||||
<p className="text-xs text-indigo-700 mb-3">Pour 4,99€/mois, utilisez vos propres clés API ChatGPT, Claude ou Gemini sans limite de tokens.</p>
|
||||
<button
|
||||
onClick={() => router.push('/pricing')}
|
||||
className="bg-white text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-200 hover:bg-indigo-50 transition w-full"
|
||||
>
|
||||
Découvrir le plan BYOK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 mt-8 pt-8 border-t border-red-100/20">
|
||||
<button className="text-red-500 text-sm font-bold hover:underline">{t('profile.delete_account')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// This file is only imported by API routes, never by client code
|
||||
|
||||
import { GoogleGenAI, Type } from "@google/genai";
|
||||
import OpenAI from "openai";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { BookProject, UserProfile } from "./types";
|
||||
|
||||
const truncate = (str: string, length: number) => {
|
||||
@@ -10,7 +12,8 @@ const truncate = (str: string, length: number) => {
|
||||
};
|
||||
|
||||
const checkUsage = (user: UserProfile) => {
|
||||
if (user.subscription.plan === 'master') return true;
|
||||
// BYOK and Master plans have unlimited AI actions
|
||||
if (user.subscription.plan === 'master' || user.subscription.plan === 'byok') return true;
|
||||
return user.usage.aiActionsCurrent < user.usage.aiActionsLimit;
|
||||
};
|
||||
|
||||
@@ -86,37 +89,77 @@ export const generateStoryContent = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const finalPrompt = buildContextPrompt(project, currentChapterId, userPrompt);
|
||||
|
||||
const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview';
|
||||
// --- BYOK: Bring Your Own Key Logic ---
|
||||
const isBYOK = user.subscription.plan === 'byok' && user.customApiKey;
|
||||
const provider = isBYOK ? (user.customApiProvider || 'openai') : 'gemini';
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: modelName,
|
||||
contents: finalPrompt,
|
||||
config: {
|
||||
if (provider === 'openai' && isBYOK) {
|
||||
const openai = new OpenAI({ apiKey: user.customApiKey });
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: finalPrompt }],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.7,
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
responseType: {
|
||||
type: Type.STRING,
|
||||
enum: ["draft", "reflection"]
|
||||
},
|
||||
content: {
|
||||
type: Type.STRING
|
||||
});
|
||||
const result = JSON.parse(completion.choices[0].message.content || "{}");
|
||||
return {
|
||||
text: result.content || "Erreur de génération.",
|
||||
type: result.responseType || "reflection"
|
||||
};
|
||||
}
|
||||
else if (provider === 'anthropic' && isBYOK) {
|
||||
const anthropic = new Anthropic({ apiKey: user.customApiKey });
|
||||
// Anthropic doesn't have strict JSON mode matching OpenAI's structure natively at this tier,
|
||||
// so we prompt it to return JSON.
|
||||
const systemPrompt = "You must respond in valid JSON format with exact keys: `responseType` (either 'draft' or 'reflection') and `content` (string).";
|
||||
const message = await anthropic.messages.create({
|
||||
model: "claude-3-haiku-20240320",
|
||||
max_tokens: 4000,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: "user", content: finalPrompt }],
|
||||
});
|
||||
const textContent = message.content[0].type === 'text' ? message.content[0].text : '{}';
|
||||
const result = JSON.parse(textContent);
|
||||
return {
|
||||
text: result.content || "Erreur de génération.",
|
||||
type: result.responseType || "reflection"
|
||||
};
|
||||
}
|
||||
else {
|
||||
// Default: Pluume Gemini implementation
|
||||
const ai = new GoogleGenAI({ apiKey: isBYOK && user.customApiProvider === 'gemini' ? user.customApiKey : process.env.GEMINI_API_KEY });
|
||||
const modelName = user.subscription.plan === 'master' ? 'gemini-3-pro-preview' : 'gemini-3-flash-preview';
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: modelName,
|
||||
contents: finalPrompt,
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
responseType: {
|
||||
type: Type.STRING,
|
||||
enum: ["draft", "reflection"]
|
||||
},
|
||||
content: {
|
||||
type: Type.STRING
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = JSON.parse(response.text || "{}");
|
||||
return {
|
||||
text: result.content || "Erreur de génération.",
|
||||
type: result.responseType || "reflection"
|
||||
};
|
||||
}
|
||||
|
||||
const result = JSON.parse(response.text || "{}");
|
||||
return {
|
||||
text: result.content || "Erreur de génération.",
|
||||
type: result.responseType || "reflection"
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("AI Generation Error:", error);
|
||||
return { text: "Erreur lors de la communication avec l'IA.", type: 'reflection' };
|
||||
@@ -130,11 +173,35 @@ export const transformTextServer = async (
|
||||
user: UserProfile,
|
||||
): Promise<string> => {
|
||||
if (!checkUsage(user)) return "Limite d'actions IA atteinte.";
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé.`;
|
||||
const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt });
|
||||
return response.text?.trim() || text;
|
||||
const prompt = `Action: ${mode}. Texte: ${text}. Contexte: ${truncate(context, 1000)}. Renvoie juste le texte transformé sans commentaires.`;
|
||||
|
||||
const isBYOK = user.subscription.plan === 'byok' && user.customApiKey;
|
||||
const provider = isBYOK ? (user.customApiProvider || 'openai') : 'gemini';
|
||||
|
||||
if (provider === 'openai' && isBYOK) {
|
||||
const openai = new OpenAI({ apiKey: user.customApiKey });
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
return completion.choices[0].message.content || text;
|
||||
}
|
||||
else if (provider === 'anthropic' && isBYOK) {
|
||||
const anthropic = new Anthropic({ apiKey: user.customApiKey });
|
||||
const message = await anthropic.messages.create({
|
||||
model: "claude-3-haiku-20240320",
|
||||
max_tokens: 4000,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
return message.content[0].type === 'text' ? message.content[0].text : text;
|
||||
}
|
||||
else {
|
||||
const ai = new GoogleGenAI({ apiKey: isBYOK && user.customApiProvider === 'gemini' ? user.customApiKey : process.env.GEMINI_API_KEY });
|
||||
const response = await ai.models.generateContent({ model: 'gemini-3-flash-preview', contents: prompt });
|
||||
return response.text?.trim() || text;
|
||||
}
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ export interface UserPreferences {
|
||||
};
|
||||
dailyWordGoal: number;
|
||||
language: 'fr' | 'en';
|
||||
customApiProvider?: 'openai' | 'anthropic' | 'gemini';
|
||||
// We can store the masked key or omit it from the client
|
||||
customApiKey?: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
@@ -182,6 +185,8 @@ export interface UserProfile {
|
||||
usage: UserUsage;
|
||||
preferences: UserPreferences;
|
||||
stats: UserStats;
|
||||
customApiProvider?: 'openai' | 'anthropic' | 'gemini';
|
||||
customApiKey?: string;
|
||||
}
|
||||
|
||||
export type ViewMode = 'write' | 'world_building' | 'workflow' | 'settings' | 'preview' | 'ideas' | 'landing' | 'features' | 'pricing' | 'checkout' | 'dashboard' | 'auth' | 'signup' | 'profile';
|
||||
|
||||
Reference in New Issue
Block a user