feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers with editable model names via Combobox in admin settings - Fix OpenRouter broken by normalizeProvider bug in config.ts - Convert agent-created notes from Markdown to HTML (TipTap rich text) - Add Notification model + in-app notifications for agent results - Agent notification click opens the created note directly - Add note count display on notebook and inbox headers - Fix checklist toggle in card view (persist state via localCheckItems) - Add checklist creation option in tabs/list view (dropdown on + button) - Fix image description ENOENT error with HTTP fallback - Improve UI contrast across all themes (input, border, checkbox visibility) - Add font family setting (Inter vs System Default) in Appearance settings - Fix CSS font-sans variable conflict (removed dead Geist references) - Update README with new features and 8 providers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,13 @@ async function requireAdmin() {
|
||||
/**
|
||||
* GET /api/admin/models?type=ollama&url=<base_url>
|
||||
* GET /api/admin/models?type=custom&url=<base_url>&key=<api_key>&kind=tags|embeddings
|
||||
* GET /api/admin/models?type=deepseek&key=<api_key>&kind=tags|embeddings
|
||||
* GET /api/admin/models?type=openrouter&key=<api_key>&kind=tags|embeddings
|
||||
* GET /api/admin/models?type=mistral&key=<api_key>&kind=tags|embeddings
|
||||
* GET /api/admin/models?type=zai&key=<api_key>&kind=tags|embeddings
|
||||
* GET /api/admin/models?type=lmstudio&url=<base_url>
|
||||
*
|
||||
* Route API (not a Server Action) for fetching AI model lists from Ollama or
|
||||
* OpenAI-compatible providers. Using a Route Handler instead of a Server Action
|
||||
* is the correct architecture for client-side GET requests: Server Actions are
|
||||
* for data mutations, and calling them from useEffect pushes items into the
|
||||
* App Router's internal action queue, which is drained during render (inside
|
||||
* AppRouter's useMemo), triggering React Error #310 when multiple calls stack up.
|
||||
* Route API for fetching AI model lists from providers.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await requireAdmin())) {
|
||||
@@ -29,14 +29,22 @@ export async function GET(request: NextRequest) {
|
||||
const apiKey = searchParams.get('key') ?? undefined
|
||||
const kind = searchParams.get('kind') ?? 'tags'
|
||||
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
||||
// Provider-specific base URLs (used when url param is empty)
|
||||
const PROVIDER_URLS: Record<string, string> = {
|
||||
deepseek: 'https://api.deepseek.com/v1',
|
||||
openrouter: 'https://openrouter.ai/api/v1',
|
||||
mistral: 'https://api.mistral.ai/v1',
|
||||
zai: 'https://api.zukijourney.com/v1',
|
||||
lmstudio: 'http://localhost:1234/v1',
|
||||
}
|
||||
|
||||
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
|
||||
try {
|
||||
// Ollama: uses native /api/tags endpoint
|
||||
if (type === 'ollama') {
|
||||
if (!rawUrl) {
|
||||
return NextResponse.json({ success: false, models: [], error: 'url parameter is required for Ollama' })
|
||||
}
|
||||
const baseUrl = rawUrl.replace(/\/$/, '').replace(/\/api$/, '')
|
||||
const res = await fetch(`${baseUrl}/api/tags`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
@@ -49,58 +57,69 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ success: true, models })
|
||||
}
|
||||
|
||||
if (type === 'custom') {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
// All other providers: use OpenAI-compatible /v1/models endpoint
|
||||
const baseUrl = rawUrl
|
||||
? rawUrl.replace(/\/$/, '')
|
||||
: (PROVIDER_URLS[type || ''] || '')
|
||||
|
||||
if (kind === 'embeddings') {
|
||||
// Try provider-specific embeddings endpoint first (e.g. OpenRouter)
|
||||
try {
|
||||
const embRes = await fetch(`${baseUrl}/v1/embeddings/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (embRes.ok) {
|
||||
const embData = await embRes.json()
|
||||
const embModels: string[] = (embData.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
if (embModels.length > 0) {
|
||||
return NextResponse.json({ success: true, models: embModels })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /v1/models with keyword filter
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
let models: string[] = (data.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
if (kind === 'embeddings') {
|
||||
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = models.filter((id) =>
|
||||
keywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
if (filtered.length > 0) models = filtered
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, models })
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ success: false, models: [], error: 'url parameter is required' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, models: [], error: `Unknown type: ${type}` })
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
|
||||
// For OpenRouter, add required headers
|
||||
if (type === 'openrouter') {
|
||||
headers['HTTP-Referer'] = 'https://localhost:3000'
|
||||
headers['X-Title'] = 'Memento AI'
|
||||
}
|
||||
|
||||
// Try provider-specific embeddings endpoint first for embeddings kind
|
||||
if (kind === 'embeddings') {
|
||||
try {
|
||||
const embRes = await fetch(`${baseUrl}/embeddings/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (embRes.ok) {
|
||||
const embData = await embRes.json()
|
||||
const embModels: string[] = (embData.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
if (embModels.length > 0) {
|
||||
return NextResponse.json({ success: true, models: embModels })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /v1/models with keyword filter
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ success: false, models: [], error: `Provider ${res.status}` })
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
let models: string[] = (data.data ?? [])
|
||||
.map((m: { id: string }) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
if (kind === 'embeddings') {
|
||||
const keywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = models.filter((id) =>
|
||||
keywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
if (filtered.length > 0) models = filtered
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, models })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ success: false, models: [], error: err.message ?? 'Request failed' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user