feat: pannello Settings con 4 sezioni flessibili + provider custom

- Redesign Settings: Testi, Immagini, Video, Voiceover — sezioni separate
- Ogni sezione ha dropdown provider + API key + campo opzionale modello
- Opzione "Personalizzato" con campo Base URL libero per qualsiasi servizio
- LLM: aggiunto OpenRouter + provider custom OpenAI-compatible
- Backend: OpenAICompatibleProvider unifica OpenAI/OpenRouter/custom
- Router content: passa llm_base_url a get_llm_provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michele
2026-03-31 18:34:24 +02:00
parent 94a9e4bb5e
commit 2c16407f96
3 changed files with 352 additions and 253 deletions

View File

@@ -63,7 +63,8 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
} }
# Create LLM provider and generate text # Create LLM provider and generate text
llm = get_llm_provider(provider_name, api_key, model) base_url = _get_setting(db, "llm_base_url")
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
text = generate_post_text( text = generate_post_text(
character=char_dict, character=char_dict,
llm_provider=llm, llm_provider=llm,

View File

@@ -1,8 +1,8 @@
""" """
Multi-LLM abstraction layer. Multi-LLM abstraction layer.
Supports Claude (Anthropic), OpenAI, and Gemini via direct HTTP calls using httpx. Supports Claude (Anthropic), OpenAI, Gemini, OpenRouter, and any
Each provider implements the same interface for text generation. OpenAI-compatible custom endpoint via direct HTTP calls using httpx.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
@@ -14,6 +14,8 @@ DEFAULT_MODELS = {
"claude": "claude-sonnet-4-20250514", "claude": "claude-sonnet-4-20250514",
"openai": "gpt-4o-mini", "openai": "gpt-4o-mini",
"gemini": "gemini-2.0-flash", "gemini": "gemini-2.0-flash",
"openrouter": "openai/gpt-4o-mini",
"custom": "",
} }
TIMEOUT = 60.0 TIMEOUT = 60.0
@@ -28,15 +30,7 @@ class LLMProvider(ABC):
@abstractmethod @abstractmethod
def generate(self, prompt: str, system: str = "") -> str: def generate(self, prompt: str, system: str = "") -> str:
"""Generate text from a prompt. """Generate text from a prompt."""
Args:
prompt: The user prompt / message.
system: Optional system prompt for context and behavior.
Returns:
Generated text string.
"""
... ...
@@ -67,7 +61,6 @@ class ClaudeProvider(LLMProvider):
response = client.post(self.API_URL, headers=headers, json=payload) response = client.post(self.API_URL, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
# Claude returns content as a list of content blocks
content_blocks = data.get("content", []) content_blocks = data.get("content", [])
return "".join( return "".join(
block.get("text", "") for block in content_blocks if block.get("type") == "text" block.get("text", "") for block in content_blocks if block.get("type") == "text"
@@ -80,15 +73,27 @@ class ClaudeProvider(LLMProvider):
raise RuntimeError(f"Claude API request failed: {e}") from e raise RuntimeError(f"Claude API request failed: {e}") from e
class OpenAIProvider(LLMProvider): class OpenAICompatibleProvider(LLMProvider):
"""OpenAI provider via Chat Completions API.""" """OpenAI Chat Completions-compatible provider.
API_URL = "https://api.openai.com/v1/chat/completions" Used for OpenAI, OpenRouter, and any custom OpenAI-compatible endpoint.
Set base_url to point to any compatible API.
"""
def __init__(self, api_key: str, model: str | None = None): DEFAULT_BASE_URL = "https://api.openai.com/v1"
super().__init__(api_key, model or DEFAULT_MODELS["openai"])
def __init__(
self,
api_key: str,
model: str | None = None,
base_url: str | None = None,
default_model: str = "gpt-4o-mini",
):
super().__init__(api_key, model or default_model)
self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
def generate(self, prompt: str, system: str = "") -> str: def generate(self, prompt: str, system: str = "") -> str:
url = f"{self.base_url}/chat/completions"
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -106,16 +111,16 @@ class OpenAIProvider(LLMProvider):
try: try:
with httpx.Client(timeout=TIMEOUT) as client: with httpx.Client(timeout=TIMEOUT) as client:
response = client.post(self.API_URL, headers=headers, json=payload) response = client.post(url, headers=headers, json=payload)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data["choices"][0]["message"]["content"] return data["choices"][0]["message"]["content"]
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
raise RuntimeError( raise RuntimeError(
f"OpenAI API error {e.response.status_code}: {e.response.text}" f"API error {e.response.status_code}: {e.response.text}"
) from e ) from e
except httpx.RequestError as e: except httpx.RequestError as e:
raise RuntimeError(f"OpenAI API request failed: {e}") from e raise RuntimeError(f"API request failed: {e}") from e
class GeminiProvider(LLMProvider): class GeminiProvider(LLMProvider):
@@ -131,7 +136,6 @@ class GeminiProvider(LLMProvider):
params = {"key": self.api_key} params = {"key": self.api_key}
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
# Build contents; Gemini uses a parts-based structure
parts: list[dict] = [] parts: list[dict] = []
if system: if system:
parts.append({"text": f"{system}\n\n{prompt}"}) parts.append({"text": f"{system}\n\n{prompt}"})
@@ -140,9 +144,7 @@ class GeminiProvider(LLMProvider):
payload = { payload = {
"contents": [{"parts": parts}], "contents": [{"parts": parts}],
"generationConfig": { "generationConfig": {"maxOutputTokens": 2048},
"maxOutputTokens": 2048,
},
} }
try: try:
@@ -165,30 +167,57 @@ class GeminiProvider(LLMProvider):
def get_llm_provider( def get_llm_provider(
provider_name: str, api_key: str, model: str | None = None provider_name: str,
api_key: str,
model: str | None = None,
base_url: str | None = None,
) -> LLMProvider: ) -> LLMProvider:
"""Factory function to get an LLM provider instance. """Factory: returns an LLMProvider instance.
Args: Args:
provider_name: One of 'claude', 'openai', 'gemini'. provider_name: 'claude', 'openai', 'gemini', 'openrouter', or 'custom'.
api_key: API key for the provider. api_key: API key for the provider.
model: Optional model override. Uses default if not specified. model: Optional model override.
base_url: Optional base URL override (used for 'openrouter' and 'custom').
Returns: Returns:
An LLMProvider instance. An LLMProvider instance ready to generate text.
Raises:
ValueError: If provider_name is not supported.
""" """
providers = { name = provider_name.lower()
"claude": ClaudeProvider,
"openai": OpenAIProvider, if name == "claude":
"gemini": GeminiProvider, return ClaudeProvider(api_key=api_key, model=model)
}
provider_cls = providers.get(provider_name.lower()) if name == "openai":
if provider_cls is None: return OpenAICompatibleProvider(
supported = ", ".join(providers.keys()) api_key=api_key,
raise ValueError( model=model,
f"Unknown LLM provider '{provider_name}'. Supported: {supported}" base_url="https://api.openai.com/v1",
default_model=DEFAULT_MODELS["openai"],
) )
return provider_cls(api_key=api_key, model=model)
if name == "gemini":
return GeminiProvider(api_key=api_key, model=model)
if name == "openrouter":
return OpenAICompatibleProvider(
api_key=api_key,
model=model,
base_url=base_url or "https://openrouter.ai/api/v1",
default_model=DEFAULT_MODELS["openrouter"],
)
if name == "custom":
if not base_url:
raise ValueError("Provider 'custom' richiede un base_url configurato nelle impostazioni.")
return OpenAICompatibleProvider(
api_key=api_key,
model=model,
base_url=base_url,
default_model=model or "",
)
raise ValueError(
f"Provider LLM '{provider_name}' non supportato. "
f"Usa: claude, openai, gemini, openrouter, custom."
)

View File

@@ -1,31 +1,46 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { api } from '../api' import { api } from '../api'
const LLM_PROVIDERS = [ // ─── Provider catalogs ────────────────────────────────────────────────────────
{ value: 'claude', label: 'Claude (Anthropic)' },
{ value: 'openai', label: 'OpenAI' }, const TEXT_PROVIDERS = [
{ value: 'gemini', label: 'Gemini (Google)' }, { value: 'claude', label: 'Claude (Anthropic)', defaultModel: 'claude-sonnet-4-20250514', needsBaseUrl: false },
{ value: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o-mini', needsBaseUrl: false },
{ value: 'gemini', label: 'Gemini (Google)', defaultModel: 'gemini-2.0-flash', needsBaseUrl: false },
{ value: 'openrouter', label: 'OpenRouter', defaultModel: 'openai/gpt-4o-mini', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', defaultModel: '', needsBaseUrl: true },
] ]
const IMAGE_PROVIDERS = [ const IMAGE_PROVIDERS = [
{ value: 'dalle', label: 'DALL-E (OpenAI)' }, { value: 'dalle', label: 'DALL-E (OpenAI)', needsBaseUrl: false },
{ value: 'replicate', label: 'Replicate' }, { value: 'replicate', label: 'Replicate', needsBaseUrl: false },
{ value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
] ]
const LLM_DEFAULTS = { const VIDEO_PROVIDERS = [
claude: 'claude-sonnet-4-20250514', { value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
openai: 'gpt-4o', { value: 'replicate', label: 'Replicate', needsBaseUrl: false },
gemini: 'gemini-2.0-flash', { value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
} ]
const cardStyle = { const VOICE_PROVIDERS = [
{ value: 'elevenlabs', label: 'ElevenLabs', needsBaseUrl: false },
{ value: 'openai_tts', label: 'OpenAI TTS', needsBaseUrl: false },
{ value: 'wavespeed', label: 'WaveSpeed', needsBaseUrl: false },
{ value: 'custom', label: 'Personalizzato (custom)', needsBaseUrl: true },
]
// ─── Styles ───────────────────────────────────────────────────────────────────
const card = {
backgroundColor: 'var(--surface)', backgroundColor: 'var(--surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: '0.75rem', borderRadius: '0.75rem',
padding: '1.5rem', padding: '1.5rem',
} }
const inputStyle = { const input = {
width: '100%', width: '100%',
padding: '0.625rem 1rem', padding: '0.625rem 1rem',
border: '1px solid var(--border)', border: '1px solid var(--border)',
@@ -34,98 +49,206 @@ const inputStyle = {
color: 'var(--ink)', color: 'var(--ink)',
backgroundColor: 'var(--cream)', backgroundColor: 'var(--cream)',
outline: 'none', outline: 'none',
boxSizing: 'border-box',
} }
export default function SettingsPage() { // ─── Section component ────────────────────────────────────────────────────────
const [settings, setSettings] = useState({})
const [providerStatus, setProviderStatus] = useState({})
const [loading, setLoading] = useState(true)
const [sectionSaving, setSectionSaving] = useState({})
const [sectionSuccess, setSectionSuccess] = useState({})
const [sectionError, setSectionError] = useState({})
const [llmForm, setLlmForm] = useState({ function ProviderSection({ title, icon, description, providers, settingKeys, values, onChange, onSave, saving, success, error }) {
const { providerKey, apiKeyKey, modelKey, baseUrlKey, extraKey, extraLabel, extraPlaceholder } = settingKeys
const currentProvider = providers.find(p => p.value === values[providerKey]) || providers[0]
const showModel = modelKey != null
const showBaseUrl = currentProvider.needsBaseUrl && baseUrlKey
return (
<div style={card} className="space-y-4">
<div className="flex items-start gap-3">
<span className="text-2xl">{icon}</span>
<div>
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
{title}
</h3>
<p className="text-xs mt-0.5" style={{ color: 'var(--muted)' }}>{description}</p>
</div>
</div>
{error && (
<div className="p-3 rounded-lg text-sm" style={{ backgroundColor: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626' }}>
{error}
</div>
)}
{success && (
<div className="p-3 rounded-lg text-sm" style={{ backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a' }}>
Salvato con successo
</div>
)}
{/* Provider dropdown */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={values[providerKey] || providers[0].value}
onChange={e => onChange(providerKey, e.target.value)}
style={input}
>
{providers.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
{/* Custom base URL (shown only for 'custom' provider) */}
{showBaseUrl && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Base URL <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(es. https://mio-provider.com/v1)</span>
</label>
<input
type="text"
value={values[baseUrlKey] || ''}
onChange={e => onChange(baseUrlKey, e.target.value)}
placeholder="https://..."
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
)}
{/* API Key */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={values[apiKeyKey] || ''}
onChange={e => onChange(apiKeyKey, e.target.value)}
placeholder="Inserisci la tua API key..."
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
{/* Model (optional, only for text/video providers) */}
{showModel && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Modello
{currentProvider.defaultModel && (
<span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>
(default: {currentProvider.defaultModel})
</span>
)}
</label>
<input
type="text"
value={values[modelKey] || ''}
onChange={e => onChange(modelKey, e.target.value)}
placeholder={currentProvider.defaultModel || 'nome-modello'}
style={{ ...input, fontFamily: 'monospace' }}
/>
</div>
)}
{/* Extra field (es. Voice ID per ElevenLabs) */}
{extraKey && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
{extraLabel} <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={values[extraKey] || ''}
onChange={e => onChange(extraKey, e.target.value)}
placeholder={extraPlaceholder}
style={input}
/>
</div>
)}
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: saving ? 0.7 : 1 }}
>
{saving ? 'Salvataggio...' : 'Salva'}
</button>
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function SettingsPage() {
const [values, setValues] = useState({
// Text
llm_provider: 'claude', llm_provider: 'claude',
llm_api_key: '', llm_api_key: '',
llm_model: '', llm_model: '',
}) llm_base_url: '',
const [imageForm, setImageForm] = useState({ // Image
image_provider: 'dalle', image_provider: 'dalle',
image_api_key: '', image_api_key: '',
}) image_base_url: '',
const [voiceForm, setVoiceForm] = useState({ // Video
elevenlabs_api_key: '', video_provider: 'wavespeed',
video_api_key: '',
video_model: '',
video_base_url: '',
// Voice
voice_provider: 'elevenlabs',
voice_api_key: '',
voice_base_url: '',
elevenlabs_voice_id: '', elevenlabs_voice_id: '',
}) })
useEffect(() => { const [loading, setLoading] = useState(true)
loadSettings() const [saving, setSaving] = useState({})
}, []) const [success, setSuccess] = useState({})
const [errors, setErrors] = useState({})
useEffect(() => { loadSettings() }, [])
const loadSettings = async () => { const loadSettings = async () => {
setLoading(true) setLoading(true)
try { try {
const [settingsData, statusData] = await Promise.all([ const data = await api.get('/settings/').catch(() => [])
api.get('/settings/').catch(() => ({})), const normalized = {}
api.get('/settings/providers/status').catch(() => ({})), if (Array.isArray(data)) {
]) data.forEach(s => { normalized[s.key] = s.value })
let normalizedSettings = {}
if (Array.isArray(settingsData)) {
settingsData.forEach((s) => { normalizedSettings[s.key] = s.value })
} else {
normalizedSettings = settingsData || {}
} }
setValues(prev => ({ ...prev, ...normalized }))
setSettings(normalizedSettings)
setProviderStatus(statusData || {})
setLlmForm({
llm_provider: normalizedSettings.llm_provider || 'claude',
llm_api_key: normalizedSettings.llm_api_key || '',
llm_model: normalizedSettings.llm_model || '',
})
setImageForm({
image_provider: normalizedSettings.image_provider || 'dalle',
image_api_key: normalizedSettings.image_api_key || '',
})
setVoiceForm({
elevenlabs_api_key: normalizedSettings.elevenlabs_api_key || '',
elevenlabs_voice_id: normalizedSettings.elevenlabs_voice_id || '',
})
} catch {
// silent
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const saveSection = async (section, data) => { const handleChange = (key, value) => {
setSectionSaving((prev) => ({ ...prev, [section]: true })) setValues(prev => ({ ...prev, [key]: value }))
setSectionSuccess((prev) => ({ ...prev, [section]: false })) }
setSectionError((prev) => ({ ...prev, [section]: '' }))
const saveSection = async (section, keys) => {
setSaving(prev => ({ ...prev, [section]: true }))
setSuccess(prev => ({ ...prev, [section]: false }))
setErrors(prev => ({ ...prev, [section]: '' }))
try { try {
for (const [key, value] of Object.entries(data)) { for (const key of keys) {
await api.put(`/settings/${key}`, { value }) if (values[key] !== undefined) {
await api.put(`/settings/${key}`, { value: values[key] })
}
} }
setSectionSuccess((prev) => ({ ...prev, [section]: true })) setSuccess(prev => ({ ...prev, [section]: true }))
setTimeout(() => { setTimeout(() => setSuccess(prev => ({ ...prev, [section]: false })), 3000)
setSectionSuccess((prev) => ({ ...prev, [section]: false }))
}, 3000)
const statusData = await api.get('/settings/providers/status').catch(() => ({}))
setProviderStatus(statusData || {})
} catch (err) { } catch (err) {
setSectionError((prev) => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' })) setErrors(prev => ({ ...prev, [section]: err.message || 'Errore nel salvataggio' }))
} finally { } finally {
setSectionSaving((prev) => ({ ...prev, [section]: false })) setSaving(prev => ({ ...prev, [section]: false }))
} }
} }
if (loading) { if (loading) {
return ( return (
<div> <div>
<h2 className="text-2xl font-bold mb-1" style={{ color: 'var(--ink)' }}>Impostazioni</h2> <h2 className="text-2xl font-bold mb-1" style={{ fontFamily: 'Fraunces, serif', color: 'var(--ink)' }}>
Impostazioni
</h2>
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} /> <div className="animate-spin rounded-full h-8 w-8 border-2 border-t-transparent" style={{ borderColor: 'var(--coral)' }} />
</div> </div>
@@ -135,152 +258,98 @@ export default function SettingsPage() {
return ( return (
<div> <div>
<div className="mb-6"> <div className="mb-8">
<h2 className="text-2xl font-bold" style={{ color: 'var(--ink)' }}>Impostazioni</h2> <h2 className="text-2xl font-bold" style={{ fontFamily: 'Fraunces, serif', color: 'var(--ink)' }}>
Impostazioni
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}> <p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Configurazione dei provider AI e dei servizi esterni Scegli il provider per ogni tipo di output. Usa "Personalizzato" per collegare qualsiasi servizio compatibile.
</p> </p>
</div> </div>
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
{/* LLM Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Provider LLM
</h3>
{sectionError.llm && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.llm}</div>}
{sectionSuccess.llm && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div> {/* TEXT */}
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label> <ProviderSection
<select title="Testi & Script"
value={llmForm.llm_provider} icon="✍️"
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_provider: e.target.value }))} description="Provider LLM per generare post, caption, script e contenuti testuali."
style={inputStyle} providers={TEXT_PROVIDERS}
> settingKeys={{
{LLM_PROVIDERS.map((p) => ( providerKey: 'llm_provider',
<option key={p.value} value={p.value}>{p.label}</option> apiKeyKey: 'llm_api_key',
))} modelKey: 'llm_model',
</select> baseUrlKey: 'llm_base_url',
</div> }}
values={values}
onChange={handleChange}
onSave={() => saveSection('text', ['llm_provider', 'llm_api_key', 'llm_model', 'llm_base_url'])}
saving={saving.text}
success={success.text}
error={errors.text}
/>
<div> {/* IMAGE */}
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label> <ProviderSection
<input title="Immagini"
type="password" icon="🖼️"
value={llmForm.llm_api_key} description="Provider per la generazione di immagini AI."
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_api_key: e.target.value }))} providers={IMAGE_PROVIDERS}
placeholder="sk-..." settingKeys={{
style={{ ...inputStyle, fontFamily: 'monospace' }} providerKey: 'image_provider',
/> apiKeyKey: 'image_api_key',
</div> baseUrlKey: 'image_base_url',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('image', ['image_provider', 'image_api_key', 'image_base_url'])}
saving={saving.image}
success={success.image}
error={errors.image}
/>
<div> {/* VIDEO */}
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}> <ProviderSection
Modello <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(default: {LLM_DEFAULTS[llmForm.llm_provider]})</span> title="Video"
</label> icon="🎬"
<input description="Provider per la generazione di video AI (testo → video, immagine → video)."
type="text" providers={VIDEO_PROVIDERS}
value={llmForm.llm_model} settingKeys={{
onChange={(e) => setLlmForm((prev) => ({ ...prev, llm_model: e.target.value }))} providerKey: 'video_provider',
placeholder={LLM_DEFAULTS[llmForm.llm_provider]} apiKeyKey: 'video_api_key',
style={{ ...inputStyle, fontFamily: 'monospace' }} modelKey: 'video_model',
/> baseUrlKey: 'video_base_url',
</div> }}
values={values}
onChange={handleChange}
onSave={() => saveSection('video', ['video_provider', 'video_api_key', 'video_model', 'video_base_url'])}
saving={saving.video}
success={success.video}
error={errors.video}
/>
<button {/* VOICE */}
onClick={() => saveSection('llm', llmForm)} <ProviderSection
disabled={sectionSaving.llm} title="Voiceover"
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity" icon="🎙️"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.llm ? 0.7 : 1 }} description="Provider text-to-speech per generare voiceover dai tuoi contenuti."
> providers={VOICE_PROVIDERS}
{sectionSaving.llm ? 'Salvataggio...' : 'Salva'} settingKeys={{
</button> providerKey: 'voice_provider',
</div> apiKeyKey: 'voice_api_key',
baseUrlKey: 'voice_base_url',
extraKey: 'elevenlabs_voice_id',
extraLabel: 'Voice ID',
extraPlaceholder: 'ID della voce (solo ElevenLabs)',
}}
values={values}
onChange={handleChange}
onSave={() => saveSection('voice', ['voice_provider', 'voice_api_key', 'voice_base_url', 'elevenlabs_voice_id'])}
saving={saving.voice}
success={success.voice}
error={errors.voice}
/>
{/* Image Provider */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Generazione Immagini
</h3>
{sectionError.image && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.image}</div>}
{sectionSuccess.image && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>Provider</label>
<select
value={imageForm.image_provider}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_provider: e.target.value }))}
style={inputStyle}
>
{IMAGE_PROVIDERS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={imageForm.image_api_key}
onChange={(e) => setImageForm((prev) => ({ ...prev, image_api_key: e.target.value }))}
placeholder="API key del provider immagini"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<button
onClick={() => saveSection('image', imageForm)}
disabled={sectionSaving.image}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.image ? 0.7 : 1 }}
>
{sectionSaving.image ? 'Salvataggio...' : 'Salva'}
</button>
</div>
{/* Voiceover */}
<div style={cardStyle} className="space-y-4">
<h3 className="font-semibold text-sm uppercase tracking-wider" style={{ color: 'var(--muted)' }}>
Voiceover (ElevenLabs)
</h3>
{sectionError.voice && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">{sectionError.voice}</div>}
{sectionSuccess.voice && <div className="p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-emerald-600 text-sm">Salvato con successo</div>}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>API Key</label>
<input
type="password"
value={voiceForm.elevenlabs_api_key}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_api_key: e.target.value }))}
placeholder="ElevenLabs API key"
style={{ ...inputStyle, fontFamily: 'monospace' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--ink)' }}>
Voice ID <span className="font-normal ml-1" style={{ color: 'var(--muted)' }}>(opzionale)</span>
</label>
<input
type="text"
value={voiceForm.elevenlabs_voice_id}
onChange={(e) => setVoiceForm((prev) => ({ ...prev, elevenlabs_voice_id: e.target.value }))}
placeholder="ID della voce ElevenLabs"
style={inputStyle}
/>
</div>
<button
onClick={() => saveSection('voice', voiceForm)}
disabled={sectionSaving.voice}
className="px-4 py-2 text-white text-sm font-medium rounded-lg transition-opacity"
style={{ backgroundColor: 'var(--coral)', opacity: sectionSaving.voice ? 0.7 : 1 }}
>
{sectionSaving.voice ? 'Salvataggio...' : 'Salva'}
</button>
</div>
</div> </div>
</div> </div>
) )