feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
|
||||
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
||||
}
|
||||
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||
})
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
|
||||
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
|
||||
)
|
||||
|
||||
// Selected Models State
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
|
||||
|
||||
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
||||
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
||||
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
tags: [],
|
||||
embeddings: [],
|
||||
chat: [],
|
||||
slides: [],
|
||||
})
|
||||
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
|
||||
tags: false,
|
||||
embeddings: false,
|
||||
chat: false,
|
||||
slides: false,
|
||||
})
|
||||
|
||||
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
|
||||
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||
}
|
||||
|
||||
if (slidesProvider === 'ollama') {
|
||||
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (slidesProvider === 'lmstudio') {
|
||||
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[slidesProvider]
|
||||
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
|
||||
if (url && key) await fetchModels('slides', slidesProvider, url, key)
|
||||
}
|
||||
}
|
||||
fetchInitial()
|
||||
|
||||
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
// Slides provider (dedicated model for deck generation — never auto-renamed)
|
||||
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
|
||||
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
|
||||
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
|
||||
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
|
||||
if (slidesProv) {
|
||||
if (slidesProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (slidesProv === 'lmstudio') {
|
||||
const url = formData.get('BASE_URL_lmstudio_slides') as string
|
||||
if (url) data.LMSTUDIO_BASE_URL = url
|
||||
} else {
|
||||
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
|
||||
if (apiKeyConfigKey) {
|
||||
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
|
||||
if (apiKey) data[apiKeyConfigKey] = apiKey
|
||||
}
|
||||
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
|
||||
if (baseUrlConfigKey) {
|
||||
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
|
||||
if (baseUrl) data[baseUrlConfigKey] = baseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slides Provider — dedicated model for deck generation */}
|
||||
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span>📑</span> {t('admin.ai.slidesProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.ai.slidesDescription')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_SLIDES"
|
||||
name="AI_PROVIDER_SLIDES"
|
||||
value={slidesProvider}
|
||||
onChange={(e) => {
|
||||
setSlidesProvider(e.target.value as AIProvider)
|
||||
setSelectedSlidesModel('')
|
||||
setDynamicModels((prev) => ({ ...prev, slides: [] }))
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={`slides-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
|
||||
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
|
||||
{t('admin.ai.slidesQuotaNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
|
||||
Reference in New Issue
Block a user