feat: add reminders page, BMad skills upgrade, MCP server refactor
- Add reminders page with navigation support - Upgrade BMad builder module to skills-based architecture - Refactor MCP server: extract tools and auth into separate modules - Add connections cache, custom AI provider support - Update prisma schema and generated client - Various UI/UX improvements and i18n updates - Add service worker for PWA support Made-with: Cursor
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
@@ -48,7 +48,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
||||
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('ai-settings')
|
||||
updateTag('ai-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
114
keep-notes/app/actions/custom-provider.ts
Normal file
114
keep-notes/app/actions/custom-provider.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
'use server'
|
||||
|
||||
interface OpenAIModel {
|
||||
id: string
|
||||
object: string
|
||||
created?: number
|
||||
owned_by?: string
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
object: string
|
||||
data: OpenAIModel[]
|
||||
}
|
||||
|
||||
async function fetchModelsFromEndpoint(
|
||||
endpoint: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIModelsResponse
|
||||
|
||||
const modelIds = (data.data || [])
|
||||
.map((m) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
return { success: true, models: modelIds }
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch provider models:', error)
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
error: error.message || 'Failed to connect to provider',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all models from a custom OpenAI-compatible provider.
|
||||
* Uses GET /v1/models (standard endpoint).
|
||||
*/
|
||||
export async function getCustomModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
return fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch embedding-specific models from a custom provider.
|
||||
* Tries GET /v1/embeddings/models first (OpenRouter-specific endpoint that returns
|
||||
* only embedding models). Falls back to GET /v1/models filtered by common
|
||||
* embedding model name patterns if the dedicated endpoint is unavailable.
|
||||
*/
|
||||
export async function getCustomEmbeddingModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
|
||||
// Try the OpenRouter-specific embeddings models endpoint first
|
||||
const embeddingsEndpoint = await fetchModelsFromEndpoint(
|
||||
`${cleanUrl}/v1/embeddings/models`,
|
||||
apiKey
|
||||
)
|
||||
|
||||
if (embeddingsEndpoint.success && embeddingsEndpoint.models.length > 0) {
|
||||
return embeddingsEndpoint
|
||||
}
|
||||
|
||||
// Fallback: fetch all models and filter by common embedding name patterns
|
||||
const allModels = await fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
|
||||
if (!allModels.success) {
|
||||
return allModels
|
||||
}
|
||||
|
||||
const embeddingKeywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = allModels.models.filter((id) =>
|
||||
embeddingKeywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
|
||||
// If the filter finds nothing, return all models so the user can still pick
|
||||
return {
|
||||
success: true,
|
||||
models: filtered.length > 0 ? filtered : allModels.models,
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,26 @@ import prisma from '@/lib/prisma'
|
||||
import { Note, CheckItem } from '@/lib/types'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
|
||||
// Helper function to parse JSON strings from database
|
||||
// Wrapper for parseNote that validates embeddings
|
||||
function parseNote(dbNote: any): Note {
|
||||
// Parse embedding
|
||||
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
|
||||
const note = parseNoteUtil(dbNote)
|
||||
|
||||
// Validate embedding if present
|
||||
if (embedding && Array.isArray(embedding)) {
|
||||
const validation = validateEmbedding(embedding)
|
||||
if (note.embedding && Array.isArray(note.embedding)) {
|
||||
const validation = validateEmbedding(note.embedding)
|
||||
if (!validation.valid) {
|
||||
// Don't include invalid embedding in the returned note
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding: null, // Exclude invalid embedding
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
...note,
|
||||
embedding: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding,
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// Helper to get hash color for labels (copied from utils)
|
||||
@@ -170,6 +153,46 @@ export async function getNotes(includeArchived = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get notes with reminders (upcoming, overdue, done)
|
||||
export async function getNotesWithReminders() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
orderBy: { reminder: 'asc' }
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes with reminders:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a reminder as done / undone
|
||||
export async function toggleReminderDone(noteId: string, done: boolean) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
data: { isReminderDone: done }
|
||||
})
|
||||
revalidatePath('/reminders')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error toggling reminder done:', error)
|
||||
return { error: 'Failed to update reminder' }
|
||||
}
|
||||
}
|
||||
|
||||
// Get archived notes only
|
||||
export async function getArchivedNotes() {
|
||||
const session = await auth();
|
||||
@@ -329,50 +352,7 @@ export async function createNote(data: {
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
let embeddingString: string | null = null;
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
if (embedding) embeddingString = JSON.stringify(embedding);
|
||||
} catch (e) {
|
||||
console.error('Embedding generation failed:', e);
|
||||
}
|
||||
|
||||
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
|
||||
let labelsToUse = data.labels || null;
|
||||
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
data.content,
|
||||
data.notebookId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
// Apply suggestions with confidence >= threshold
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label);
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
labelsToUse = appliedLabels;
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
|
||||
// Continue without auto-labeling on error
|
||||
}
|
||||
}
|
||||
|
||||
// Save note to DB immediately (fast!) — AI operations run in background after
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
@@ -381,27 +361,83 @@ export async function createNote(data: {
|
||||
color: data.color || 'default',
|
||||
type: data.type || 'text',
|
||||
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
||||
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
|
||||
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
links: data.links ? JSON.stringify(data.links) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
reminder: data.reminder || null,
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
size: data.size || 'small',
|
||||
embedding: embeddingString,
|
||||
embedding: null, // Generated in background
|
||||
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
|
||||
autoGenerated: data.autoGenerated || null,
|
||||
notebookId: data.notebookId || null, // Assign note to notebook if provided
|
||||
notebookId: data.notebookId || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Sync labels to ensure Label records exist
|
||||
if (labelsToUse && labelsToUse.length > 0) {
|
||||
await syncLabels(session.user.id, labelsToUse)
|
||||
// Sync user-provided labels immediately
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
await syncLabels(session.user.id, data.labels)
|
||||
}
|
||||
|
||||
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||
revalidatePath('/')
|
||||
|
||||
// Fire-and-forget: run AI operations in background without blocking the response
|
||||
const userId = session.user.id
|
||||
const noteId = note.id
|
||||
const content = data.content
|
||||
const notebookId = data.notebookId
|
||||
const hasUserLabels = data.labels && data.labels.length > 0
|
||||
|
||||
// Use setImmediate-like pattern to not block the response
|
||||
;(async () => {
|
||||
try {
|
||||
// Background task 1: Generate embedding
|
||||
const provider = getAIProvider(await getSystemConfig())
|
||||
const embedding = await provider.getEmbeddings(content)
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding generation failed:', e)
|
||||
}
|
||||
|
||||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||||
if (!hasUserLabels && notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
userId
|
||||
)
|
||||
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label)
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { labels: JSON.stringify(appliedLabels) }
|
||||
})
|
||||
await syncLabels(userId, appliedLabels)
|
||||
revalidatePath('/')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BG] Auto-labeling failed:', error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return parseNote(note)
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
@@ -433,23 +469,42 @@ export async function updateNote(id: string, data: {
|
||||
try {
|
||||
const oldNote = await prisma.note.findUnique({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { labels: true, notebookId: true }
|
||||
select: { labels: true, notebookId: true, reminder: true }
|
||||
})
|
||||
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
||||
const oldNotebookId = oldNote?.notebookId
|
||||
|
||||
const updateData: any = { ...data }
|
||||
|
||||
if (data.content !== undefined) {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
updateData.embedding = embedding ? JSON.stringify(embedding) : null;
|
||||
} catch (e) {
|
||||
console.error('Embedding regeneration failed:', e);
|
||||
// Reset isReminderDone only when reminder date actually changes (not on every save)
|
||||
if ('reminder' in data && data.reminder !== null) {
|
||||
const newTime = new Date(data.reminder as Date).getTime()
|
||||
const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null
|
||||
if (newTime !== oldTime) {
|
||||
updateData.isReminderDone = false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding in background — don't block the update
|
||||
if (data.content !== undefined) {
|
||||
const noteId = id
|
||||
const content = data.content
|
||||
;(async () => {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(content);
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding regeneration failed:', e);
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||
@@ -766,13 +821,25 @@ export async function getAllNotes(includeArchived = false) {
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out archived shared notes if needed
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
|
||||
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
|
||||
// Derive pinned and recent notes
|
||||
const pinned = allNotes.filter((note: Note) => note.isPinned)
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
const recent = allNotes
|
||||
.filter((note: Note) => {
|
||||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||||
})
|
||||
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
return allNotes
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
@@ -808,6 +875,7 @@ export async function getPinnedNotes(notebookId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
export async function getRecentNotes(limit: number = 3) {
|
||||
const session = await auth();
|
||||
@@ -824,7 +892,8 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
where: {
|
||||
userId: userId,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false
|
||||
isArchived: false,
|
||||
dismissedFromRecent: false // Filter out dismissed notes
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
take: limit
|
||||
@@ -837,6 +906,25 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss a note from Recent section
|
||||
export async function dismissFromRecent(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { dismissedFromRecent: true }
|
||||
})
|
||||
|
||||
// revalidatePath('/') // Removed to prevent immediate refill of the list
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error dismissing note from recent:', error)
|
||||
throw new Error('Failed to dismiss note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteById(noteId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
@@ -28,7 +28,7 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('user-settings')
|
||||
updateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user