fix(ui): thème, textes et lisibilité restants de la revue
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
23
memento-note/lib/ai/byok-catalog.ts
Normal file
23
memento-note/lib/ai/byok-catalog.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getAllowedByokProviders } from '@/lib/byok'
|
||||
import { getLivePublicModels } from '@/lib/ai/live-public-catalog'
|
||||
import { providerDisplayName } from '@/lib/ai/provider-labels'
|
||||
|
||||
const ENDPOINT_ONLY = new Set(['custom', 'custom_openai', 'custom_anthropic', 'anthropic_custom'])
|
||||
|
||||
export type PublicByokProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
/** Catalogue public : fournisseurs BYOK Business, hors adresses perso. */
|
||||
export async function getPublicByokCatalog(): Promise<PublicByokProvider[]> {
|
||||
const liveModels = await getLivePublicModels()
|
||||
return getAllowedByokProviders('BUSINESS')
|
||||
.filter((id) => !ENDPOINT_ONLY.has(id))
|
||||
.map((id) => ({
|
||||
id,
|
||||
name: providerDisplayName(id),
|
||||
models: liveModels[id] ?? [],
|
||||
}))
|
||||
}
|
||||
205
memento-note/lib/ai/live-public-catalog.ts
Normal file
205
memento-note/lib/ai/live-public-catalog.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
|
||||
import { redis } from '@/lib/redis'
|
||||
|
||||
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
||||
const REDIS_KEY = 'memento:public-byok-models:v2'
|
||||
const REDIS_TTL_SEC = 12 * 60 * 60
|
||||
const MEMORY_TTL_MS = 30 * 60 * 1000
|
||||
const DISPLAY_PER_PROVIDER = 3
|
||||
|
||||
type OpenRouterModel = {
|
||||
id?: string
|
||||
created?: number
|
||||
architecture?: {
|
||||
modality?: string
|
||||
output_modalities?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const VENDOR_TO_PROVIDER: Record<string, string> = {
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
google: 'google',
|
||||
deepseek: 'deepseek',
|
||||
minimax: 'minimax',
|
||||
mistralai: 'mistral',
|
||||
'z-ai': 'glm',
|
||||
}
|
||||
|
||||
let memoryCache: { at: number; models: Record<string, string[]> } | null = null
|
||||
|
||||
function slugOf(id: string): string {
|
||||
return (id.includes('/') ? id.split('/')[1] : id).replace(/:.*$/, '')
|
||||
}
|
||||
|
||||
function isNoisyId(id: string): boolean {
|
||||
const slug = slugOf(id)
|
||||
if (/:(batch|free|nitro)/i.test(id)) return true
|
||||
if (/embed/i.test(slug)) return true
|
||||
if (/-fast$/i.test(slug)) return true
|
||||
if (/-vision|-image|-audio|-preview|-exp|codex|oss|safeguard/i.test(slug)) return true
|
||||
if (/gemma|lyria|voxtral|ministral|devstral|codestral/i.test(slug)) return true
|
||||
if (/chat-latest/i.test(slug)) return true
|
||||
if (/-\d{4,8}$/.test(slug)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function cleanliness(id: string): number {
|
||||
const slug = slugOf(id)
|
||||
let score = 0
|
||||
if (/-pro$/i.test(slug)) score += 20
|
||||
if (/-lite$/i.test(slug)) score += 5
|
||||
return score
|
||||
}
|
||||
|
||||
function isChatModel(model: OpenRouterModel): boolean {
|
||||
const id = model.id ?? ''
|
||||
if (!id || isNoisyId(id)) return false
|
||||
const outputs = model.architecture?.output_modalities
|
||||
if (outputs && !outputs.includes('text')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function familyKey(id: string): string {
|
||||
return slugOf(id)
|
||||
.replace(/-pro$/i, '')
|
||||
.replace(/-latest$/i, '')
|
||||
}
|
||||
|
||||
function nativeName(openRouterId: string): string {
|
||||
const slug = openRouterId.includes('/') ? openRouterId.split('/')[1] : openRouterId
|
||||
if (/^minimax-/i.test(slug)) {
|
||||
return slug.replace(/^minimax-/i, 'MiniMax-').replace(/-m(\d)/i, '-M$1')
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
function pickLatestIds(models: OpenRouterModel[], keepPrefix: boolean): string[] {
|
||||
const byFamily = new Map<string, OpenRouterModel[]>()
|
||||
const order: string[] = []
|
||||
const sorted = [...models].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
|
||||
for (const model of sorted) {
|
||||
const id = model.id
|
||||
if (!id) continue
|
||||
const key = familyKey(id)
|
||||
if (!byFamily.has(key)) {
|
||||
byFamily.set(key, [])
|
||||
order.push(key)
|
||||
}
|
||||
byFamily.get(key)!.push(model)
|
||||
}
|
||||
|
||||
const picked: string[] = []
|
||||
for (const key of order) {
|
||||
const best = [...(byFamily.get(key) ?? [])].sort(
|
||||
(a, b) => cleanliness(a.id ?? '') - cleanliness(b.id ?? ''),
|
||||
)[0]
|
||||
const id = best?.id
|
||||
if (!id) continue
|
||||
picked.push(keepPrefix ? id.replace(/:.*$/, '') : nativeName(id))
|
||||
if (picked.length >= DISPLAY_PER_PROVIDER) break
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
function buildFromOpenRouter(models: OpenRouterModel[]): Record<string, string[]> {
|
||||
const byVendor = new Map<string, OpenRouterModel[]>()
|
||||
for (const model of models) {
|
||||
if (!isChatModel(model) || !model.id?.includes('/')) continue
|
||||
const vendor = model.id.split('/')[0]
|
||||
const list = byVendor.get(vendor) ?? []
|
||||
list.push(model)
|
||||
byVendor.set(vendor, list)
|
||||
}
|
||||
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [vendor, provider] of Object.entries(VENDOR_TO_PROVIDER)) {
|
||||
result[provider] = pickLatestIds(byVendor.get(vendor) ?? [], false)
|
||||
}
|
||||
|
||||
const showcaseVendors = ['openai', 'anthropic', 'google']
|
||||
result.openrouter = showcaseVendors
|
||||
.map((vendor) => {
|
||||
const latest = pickLatestIds(byVendor.get(vendor) ?? [], true)[0]
|
||||
return latest
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
result.zai = [
|
||||
result.openai?.[0],
|
||||
result.anthropic?.[0],
|
||||
result.google?.[0],
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchOpenRouterModels(): Promise<OpenRouterModel[]> {
|
||||
const response = await fetch(OPENROUTER_MODELS_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'HTTP-Referer': process.env.NEXTAUTH_URL || 'https://memento-note.com',
|
||||
'X-Title': 'Memento',
|
||||
},
|
||||
signal: AbortSignal.timeout(8000),
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`catalog ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as { data?: OpenRouterModel[] }
|
||||
return Array.isArray(data.data) ? data.data : []
|
||||
}
|
||||
|
||||
function mergeWithFallback(live: Record<string, string[]>): Record<string, string[]> {
|
||||
const merged: Record<string, string[]> = { ...PROVIDER_MODEL_SUGGESTIONS }
|
||||
for (const [id, models] of Object.entries(live)) {
|
||||
if (models.length > 0) merged[id] = models
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
async function readRedis(): Promise<Record<string, string[]> | null> {
|
||||
try {
|
||||
const raw = await redis.get(REDIS_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Record<string, string[]>
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRedis(models: Record<string, string[]>): Promise<void> {
|
||||
try {
|
||||
await redis.set(REDIS_KEY, JSON.stringify(models), 'EX', REDIS_TTL_SEC)
|
||||
} catch {
|
||||
/* le cache mémoire suffit */
|
||||
}
|
||||
}
|
||||
|
||||
/** Noms de modèles à jour, lus périodiquement — jamais une liste figée seule. */
|
||||
export async function getLivePublicModels(): Promise<Record<string, string[]>> {
|
||||
if (memoryCache && Date.now() - memoryCache.at < MEMORY_TTL_MS) {
|
||||
return memoryCache.models
|
||||
}
|
||||
|
||||
const cached = await readRedis()
|
||||
if (cached) {
|
||||
memoryCache = { at: Date.now(), models: cached }
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const remote = await fetchOpenRouterModels()
|
||||
const merged = mergeWithFallback(buildFromOpenRouter(remote))
|
||||
memoryCache = { at: Date.now(), models: merged }
|
||||
await writeRedis(merged)
|
||||
return merged
|
||||
} catch (error) {
|
||||
console.warn('[live-public-catalog] lecture distante impossible, liste de secours', error)
|
||||
const fallback = { ...PROVIDER_MODEL_SUGGESTIONS }
|
||||
memoryCache = { at: Date.now(), models: fallback }
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,15 @@ const PROVIDER_URLS: Record<string, string> = {
|
||||
|
||||
// Fallback popular models when live fetching fails or for providers without /models endpoint (e.g. Anthropic, Google)
|
||||
export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
|
||||
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
|
||||
deepseek: ['deepseek-chat', 'deepseek-coder'],
|
||||
minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'],
|
||||
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
|
||||
glm: ['glm-4', 'glm-4-flash'],
|
||||
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
|
||||
openai: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
|
||||
anthropic: ['claude-opus-5', 'claude-sonnet-5', 'claude-fable-5'],
|
||||
google: ['gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash-lite'],
|
||||
deepseek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat'],
|
||||
minimax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
|
||||
mistral: ['mistral-medium-latest', 'mistral-small-latest', 'mistral-large-latest'],
|
||||
glm: ['glm-5.3', 'glm-5.3-flash', 'glm-5.2'],
|
||||
openrouter: ['openai/gpt-5.6-sol', 'anthropic/claude-opus-5', 'google/gemini-3.7-flash'],
|
||||
zai: ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-3.7-flash'],
|
||||
custom: [],
|
||||
};
|
||||
|
||||
|
||||
20
memento-note/lib/ai/provider-labels.ts
Normal file
20
memento-note/lib/ai/provider-labels.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Noms affichés des fournisseurs — une seule source pour réglages et page publique. */
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
minimax: 'MiniMax',
|
||||
google: 'Google AI',
|
||||
deepseek: 'DeepSeek',
|
||||
openrouter: 'OpenRouter',
|
||||
mistral: 'Mistral AI',
|
||||
glm: 'GLM (Zhipu)',
|
||||
zai: 'Zuki Journey',
|
||||
anthropic_custom: 'Anthropic (custom)',
|
||||
custom_openai: 'Compatible OpenAI',
|
||||
custom_anthropic: 'Compatible Anthropic',
|
||||
custom: 'Custom API',
|
||||
}
|
||||
|
||||
export function providerDisplayName(provider: string): string {
|
||||
return PROVIDER_LABELS[provider] ?? provider
|
||||
}
|
||||
Reference in New Issue
Block a user