feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning
- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert) all buttons now icon-only with title tooltip for accessibility - Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu - Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory now checks note.historyEnabled directly — unblocks manual per-note history - Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel calls commitNoteHistory with visual feedback (spinner → success state) - note-document-info-panel: import commitNoteHistory, add isSavingVersion state - notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
@@ -12,12 +12,33 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type AIProvider = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio'
|
||||
type AIProvider =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
|
||||
/** Providers that cannot be used for embeddings in Memento (no embedding API wired). */
|
||||
const PROVIDERS_WITHOUT_EMBEDDINGS: AIProvider[] = ['anthropic', 'anthropic_custom']
|
||||
|
||||
// Provider config metadata
|
||||
const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: string; hasApiKey: boolean; hasBaseUrl: boolean; isLocal: boolean }> = {
|
||||
ollama: { apiKeyLabel: '', baseUrlLabel: 'admin.ai.baseUrl', hasApiKey: false, hasBaseUrl: true, isLocal: true },
|
||||
openai: { apiKeyLabel: 'OPENAI_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||
anthropic: { apiKeyLabel: 'ANTHROPIC_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||
anthropic_custom: {
|
||||
apiKeyLabel: 'ANTHROPIC_CUSTOM_API_KEY',
|
||||
baseUrlLabel: 'admin.ai.baseUrl',
|
||||
hasApiKey: true,
|
||||
hasBaseUrl: true,
|
||||
isLocal: false,
|
||||
},
|
||||
deepseek: { apiKeyLabel: 'DEEPSEEK_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||
openrouter:{ apiKeyLabel: 'OPENROUTER_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||
mistral: { apiKeyLabel: 'MISTRAL_API_KEY', baseUrlLabel: '', hasApiKey: true, hasBaseUrl: false, isLocal: false },
|
||||
@@ -30,6 +51,8 @@ const PROVIDER_META: Record<AIProvider, { apiKeyLabel: string; baseUrlLabel: str
|
||||
const API_KEY_CONFIG: Record<AIProvider, string> = {
|
||||
ollama: '',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
anthropic_custom: 'ANTHROPIC_CUSTOM_API_KEY',
|
||||
deepseek: 'DEEPSEEK_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
mistral: 'MISTRAL_API_KEY',
|
||||
@@ -41,6 +64,8 @@ const API_KEY_CONFIG: Record<AIProvider, string> = {
|
||||
const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
||||
ollama: 'OLLAMA_BASE_URL',
|
||||
openai: '',
|
||||
anthropic: '',
|
||||
anthropic_custom: 'ANTHROPIC_CUSTOM_BASE_URL',
|
||||
deepseek: '',
|
||||
openrouter: '',
|
||||
mistral: '',
|
||||
@@ -52,6 +77,8 @@ const BASE_URL_CONFIG: Record<AIProvider, string> = {
|
||||
const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||
ollama: 'http://localhost:11434',
|
||||
openai: '',
|
||||
anthropic: '',
|
||||
anthropic_custom: '',
|
||||
deepseek: 'https://api.deepseek.com/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
mistral: 'https://api.mistral.ai/v1',
|
||||
@@ -63,6 +90,24 @@ const DEFAULT_BASE_URLS: Record<AIProvider, string> = {
|
||||
// Suggested models per provider (shown as hints in Combobox - user can always type a custom name)
|
||||
const SUGGESTED_MODELS: Record<string, string[]> = {
|
||||
openai: ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano', 'gpt-4o', 'gpt-4o-mini', 'o3-mini', 'o4-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||
anthropic: [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-5',
|
||||
'claude-haiku-4-5',
|
||||
'claude-3-haiku-20240307',
|
||||
],
|
||||
anthropic_custom: [
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.7-highspeed',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.5-highspeed',
|
||||
'MiniMax-M2.1',
|
||||
'MiniMax-M2.1-highspeed',
|
||||
'MiniMax-M2',
|
||||
'claude-sonnet-4-20250514',
|
||||
],
|
||||
openrouter: ['openai/gpt-4o-mini', 'openai/gpt-4.1-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-flash-preview', 'google/gemma-4-26b-a4b-it', 'meta-llama/llama-4-maverick', 'deepseek/deepseek-chat-v3-0324'],
|
||||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', 'codestral-latest', 'mistral-embed'],
|
||||
@@ -100,7 +145,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
// AI Provider state - separated for tags, embeddings, and chat
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>(() => {
|
||||
const v = (config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama'
|
||||
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||
})
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
|
||||
// Selected Models State
|
||||
@@ -170,7 +218,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
await fetchModels('tags', 'ollama', config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (tagsProvider === 'lmstudio') {
|
||||
await fetchModels('tags', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[tagsProvider]?.hasApiKey) {
|
||||
} else if (PROVIDER_META[tagsProvider]?.hasApiKey && tagsProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[tagsProvider]
|
||||
const key = config[API_KEY_CONFIG[tagsProvider]] || ''
|
||||
if (url && key) await fetchModels('tags', tagsProvider, url, key)
|
||||
@@ -180,7 +228,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
await fetchModels('embeddings', 'ollama', config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (embeddingsProvider === 'lmstudio') {
|
||||
await fetchModels('embeddings', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey) {
|
||||
} else if (PROVIDER_META[embeddingsProvider]?.hasApiKey && embeddingsProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[embeddingsProvider]
|
||||
const key = config[API_KEY_CONFIG[embeddingsProvider]] || ''
|
||||
if (url && key) await fetchModels('embeddings', embeddingsProvider, url, key)
|
||||
@@ -190,7 +238,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
await fetchModels('chat', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (chatProvider === 'lmstudio') {
|
||||
await fetchModels('chat', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[chatProvider]?.hasApiKey) {
|
||||
} else if (PROVIDER_META[chatProvider]?.hasApiKey && chatProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[chatProvider]
|
||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||
@@ -459,13 +507,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
? (config.LMSTUDIO_BASE_URL || DEFAULT_BASE_URLS.lmstudio)
|
||||
: (config[BASE_URL_CONFIG[provider]] || DEFAULT_BASE_URLS[provider] || '')
|
||||
}
|
||||
placeholder={DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')}
|
||||
placeholder={
|
||||
provider === 'anthropic_custom'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: DEFAULT_BASE_URLS[provider] || t('admin.ai.baseUrl')
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (provider === 'anthropic_custom') {
|
||||
toast.info(t('admin.ai.anthropicCustomNoModelList'))
|
||||
return
|
||||
}
|
||||
const urlInput = document.getElementById(`BASE_URL_${provider}_${purpose}`) as HTMLInputElement
|
||||
const keyInput = meta.hasApiKey
|
||||
? document.getElementById(`API_KEY_${provider}_${purpose}`) as HTMLInputElement
|
||||
@@ -474,7 +530,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const key = keyInput?.value || (meta.hasApiKey ? config[API_KEY_CONFIG[provider]] : undefined)
|
||||
if (url) fetchModels(purpose, provider, url, key)
|
||||
}}
|
||||
disabled={loading}
|
||||
disabled={loading || provider === 'anthropic_custom'}
|
||||
title={t('admin.ai.refreshModels')}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
@@ -500,7 +556,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const key = keyInput?.value || config[API_KEY_CONFIG[provider]] || ''
|
||||
if (url && key) fetchModels(purpose, provider, url, key)
|
||||
}}
|
||||
disabled={loading}
|
||||
disabled={loading || provider === 'anthropic'}
|
||||
title={t('admin.ai.refreshModels')}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
@@ -525,9 +581,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
? t('admin.ai.fetchingModels')
|
||||
: dynamicModels[purpose].length > 0
|
||||
? t('admin.ai.modelsAvailable', { count: dynamicModels[purpose].length })
|
||||
: provider === 'ollama' || provider === 'lmstudio'
|
||||
? t('admin.ai.selectOllamaModel')
|
||||
: t('admin.ai.enterUrlToLoad')}
|
||||
: provider === 'anthropic'
|
||||
? t('admin.ai.anthropicModelHint')
|
||||
: provider === 'anthropic_custom'
|
||||
? t('admin.ai.anthropicCustomModelHint')
|
||||
: provider === 'ollama' || provider === 'lmstudio'
|
||||
? t('admin.ai.selectOllamaModel')
|
||||
: t('admin.ai.enterUrlToLoad')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,6 +598,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const providerOptions = [
|
||||
{ value: 'ollama', label: t('admin.ai.providerOllamaOption') },
|
||||
{ value: 'openai', label: t('admin.ai.providerOpenAIOption') },
|
||||
{ value: 'anthropic', label: t('admin.ai.providerAnthropicOption') },
|
||||
{ value: 'anthropic_custom', label: t('admin.ai.providerAnthropicCustomOption') },
|
||||
{ value: 'deepseek', label: t('admin.ai.providerDeepSeekOption') },
|
||||
{ value: 'openrouter', label: t('admin.ai.providerOpenRouterOption') },
|
||||
{ value: 'mistral', label: t('admin.ai.providerMistralOption') },
|
||||
@@ -546,6 +608,10 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
{ value: 'custom', label: t('admin.ai.providerCustomOption') },
|
||||
]
|
||||
|
||||
const embeddingsProviderOptions = providerOptions.filter(
|
||||
(opt) => !PROVIDERS_WITHOUT_EMBEDDINGS.includes(opt.value as AIProvider)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="columns-1 lg:columns-2 gap-6">
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden break-inside-avoid mb-6">
|
||||
@@ -657,7 +723,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}}
|
||||
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 => (
|
||||
{embeddingsProviderOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user