chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
73
memento-note/app/actions/admin-settings.ts
Normal file
73
memento-note/app/actions/admin-settings.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { revalidateTag } from 'next/cache'
|
||||
|
||||
async function checkAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
throw new Error('Unauthorized: Admin access required')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function testEmail(provider: 'resend' | 'smtp' = 'smtp') {
|
||||
const session = await checkAdmin()
|
||||
const email = session.user?.email
|
||||
|
||||
if (!email) throw new Error("No admin email found")
|
||||
|
||||
const subject = provider === 'resend'
|
||||
? "Memento Resend Test"
|
||||
: "Memento SMTP Test"
|
||||
|
||||
const html = provider === 'resend'
|
||||
? "<p>This is a test email from your Memento instance. <strong>Resend is working!</strong></p>"
|
||||
: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
}, provider)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getSystemConfig() {
|
||||
await checkAdmin()
|
||||
// Reuse the cached version from lib/config
|
||||
const { getSystemConfig: getCachedConfig } = await import('@/lib/config')
|
||||
return getCachedConfig()
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(data: Record<string, string>) {
|
||||
await checkAdmin()
|
||||
|
||||
try {
|
||||
// Filter out empty values but keep 'false' as valid
|
||||
const filteredData = Object.fromEntries(
|
||||
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
|
||||
)
|
||||
|
||||
const operations = Object.entries(filteredData).map(([key, value]) =>
|
||||
prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value }
|
||||
})
|
||||
)
|
||||
|
||||
await prisma.$transaction(operations)
|
||||
|
||||
// Invalidate cache after update
|
||||
revalidateTag('system-config', '/settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to update settings:', error)
|
||||
return { error: 'Failed to update settings' }
|
||||
}
|
||||
}
|
||||
120
memento-note/app/actions/admin.ts
Normal file
120
memento-note/app/actions/admin.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Schema pour la création d'utilisateur
|
||||
const CreateUserSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
role: z.enum(["USER", "ADMIN"]).default("USER"),
|
||||
})
|
||||
|
||||
async function checkAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
|
||||
throw new Error('Unauthorized: Admin access required')
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function getUsers() {
|
||||
await checkAdmin()
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
return users
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error)
|
||||
throw new Error('Failed to fetch users')
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUser(formData: FormData) {
|
||||
await checkAdmin()
|
||||
|
||||
const rawData = {
|
||||
name: formData.get('name'),
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
role: formData.get('role'),
|
||||
}
|
||||
|
||||
const validatedFields = CreateUserSchema.safeParse(rawData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
error: validatedFields.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { name, email, password, role } = validatedFields.data
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
role,
|
||||
},
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
return { error: { email: ['Email already exists'] } }
|
||||
}
|
||||
return { error: { _form: ['Failed to create user'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
const session = await checkAdmin()
|
||||
|
||||
if (session.user?.id === userId) {
|
||||
throw new Error("You cannot delete your own account")
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to delete user')
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserRole(userId: string, newRole: string) {
|
||||
const session = await checkAdmin()
|
||||
|
||||
if (session.user?.id === userId) {
|
||||
throw new Error("You cannot change your own role")
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role: newRole },
|
||||
})
|
||||
revalidatePath('/admin')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
throw new Error('Failed to update role')
|
||||
}
|
||||
}
|
||||
235
memento-note/app/actions/agent-actions.ts
Normal file
235
memento-note/app/actions/agent-actions.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
'use server'
|
||||
|
||||
/**
|
||||
* Agent Server Actions
|
||||
* CRUD operations for agents and execution triggers.
|
||||
*/
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
export async function createAgent(data: {
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
role: string
|
||||
sourceUrls?: string[]
|
||||
sourceNotebookId?: string
|
||||
targetNotebookId?: string
|
||||
frequency?: string
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
role: data.role,
|
||||
sourceUrls: data.sourceUrls ? JSON.stringify(data.sourceUrls) : null,
|
||||
sourceNotebookId: data.sourceNotebookId || null,
|
||||
targetNotebookId: data.targetNotebookId || null,
|
||||
frequency: data.frequency || 'manual',
|
||||
tools: data.tools ? JSON.stringify(data.tools) : '[]',
|
||||
maxSteps: data.maxSteps || 10,
|
||||
notifyEmail: data.notifyEmail || false,
|
||||
includeImages: data.includeImages || false,
|
||||
userId: session.user.id,
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/agents')
|
||||
return { success: true, agent }
|
||||
} catch (error) {
|
||||
console.error('Error creating agent:', error)
|
||||
throw new Error('Impossible de creer l\'agent')
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAgent(id: string, data: {
|
||||
name?: string
|
||||
description?: string
|
||||
type?: string
|
||||
role?: string
|
||||
sourceUrls?: string[]
|
||||
sourceNotebookId?: string | null
|
||||
targetNotebookId?: string | null
|
||||
frequency?: string
|
||||
isEnabled?: boolean
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.agent.findUnique({ where: { id } })
|
||||
if (!existing || existing.userId !== session.user.id) {
|
||||
throw new Error('Agent non trouve')
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.description !== undefined) updateData.description = data.description
|
||||
if (data.type !== undefined) updateData.type = data.type
|
||||
if (data.role !== undefined) updateData.role = data.role
|
||||
if (data.sourceUrls !== undefined) updateData.sourceUrls = JSON.stringify(data.sourceUrls)
|
||||
if (data.sourceNotebookId !== undefined) updateData.sourceNotebookId = data.sourceNotebookId
|
||||
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
|
||||
if (data.frequency !== undefined) updateData.frequency = data.frequency
|
||||
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
|
||||
if (data.tools !== undefined) updateData.tools = JSON.stringify(data.tools)
|
||||
if (data.maxSteps !== undefined) updateData.maxSteps = data.maxSteps
|
||||
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
|
||||
if (data.includeImages !== undefined) updateData.includeImages = data.includeImages
|
||||
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
revalidatePath('/agents')
|
||||
return { success: true, agent }
|
||||
} catch (error) {
|
||||
console.error('Error updating agent:', error)
|
||||
throw new Error('Impossible de mettre a jour l\'agent')
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.agent.findUnique({ where: { id } })
|
||||
if (!existing || existing.userId !== session.user.id) {
|
||||
throw new Error('Agent non trouve')
|
||||
}
|
||||
|
||||
await prisma.agent.delete({ where: { id } })
|
||||
revalidatePath('/agents')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent:', error)
|
||||
throw new Error('Impossible de supprimer l\'agent')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgents() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const agents = await prisma.agent.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
_count: { select: { actions: true } },
|
||||
actions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
notebook: {
|
||||
select: { id: true, name: true, icon: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return agents
|
||||
} catch (error) {
|
||||
console.error('Error fetching agents:', error)
|
||||
throw new Error('Impossible de charger les agents')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execution ---
|
||||
|
||||
export async function runAgent(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(id, session.user.id)
|
||||
revalidatePath('/agents')
|
||||
revalidatePath('/')
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error running agent:', error)
|
||||
return {
|
||||
success: false,
|
||||
actionId: '',
|
||||
error: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- History ---
|
||||
|
||||
export async function getAgentActions(agentId: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const actions = await prisma.agentAction.findMany({
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
result: true,
|
||||
log: true,
|
||||
input: true,
|
||||
toolLog: true,
|
||||
tokensUsed: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
return actions
|
||||
} catch (error) {
|
||||
console.error('Error fetching agent actions:', error)
|
||||
throw new Error('Impossible de charger l\'historique')
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAgent(id: string, isEnabled: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Non autorise')
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id },
|
||||
data: { isEnabled }
|
||||
})
|
||||
return { success: true, agent }
|
||||
} catch (error) {
|
||||
console.error('Error toggling agent:', error)
|
||||
throw new Error('Impossible de modifier l\'agent')
|
||||
}
|
||||
}
|
||||
382
memento-note/app/actions/ai-actions.ts
Normal file
382
memento-note/app/actions/ai-actions.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
'use server'
|
||||
|
||||
/**
|
||||
* AI Server Actions Stub File
|
||||
*
|
||||
* This file provides a centralized location for all AI-related server action interfaces
|
||||
* and serves as documentation for the AI server action architecture.
|
||||
*
|
||||
* IMPLEMENTATION STATUS:
|
||||
* - Title Suggestions: ✅ Implemented (see app/actions/title-suggestions.ts)
|
||||
* - Semantic Search: ✅ Implemented (see app/actions/semantic-search.ts)
|
||||
* - Paragraph Reformulation: ✅ Implemented (see app/actions/paragraph-refactor.ts)
|
||||
* - Memory Echo: ⏳ STUB - To be implemented in Epic 5 (Story 5-1)
|
||||
* - Language Detection: ✅ Implemented (see app/actions/detect-language.ts)
|
||||
* - AI Settings: ✅ Implemented (see app/actions/ai-settings.ts)
|
||||
*
|
||||
* NOTE: This file defines TypeScript interfaces and placeholder functions.
|
||||
* Actual implementations are in separate action files (see references above).
|
||||
*/
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
// ============================================================================
|
||||
// TYPESCRIPT INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Title Suggestions Interfaces
|
||||
* @see app/actions/title-suggestions.ts for implementation
|
||||
*/
|
||||
export interface GenerateTitlesRequest {
|
||||
noteId: string
|
||||
}
|
||||
|
||||
export interface GenerateTitlesResponse {
|
||||
suggestions: Array<{
|
||||
title: string
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}>
|
||||
noteId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Search Interfaces
|
||||
* @see app/actions/semantic-search.ts for implementation
|
||||
*/
|
||||
export interface SearchResult {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
similarity: number
|
||||
matchType: 'exact' | 'related'
|
||||
}
|
||||
|
||||
export interface SemanticSearchRequest {
|
||||
query: string
|
||||
options?: {
|
||||
limit?: number
|
||||
threshold?: number
|
||||
notebookId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SearchResult[]
|
||||
query: string
|
||||
totalResults: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph Reformulation Interfaces
|
||||
* @see app/actions/paragraph-refactor.ts for implementation
|
||||
*/
|
||||
export type RefactorMode = 'clarify' | 'shorten' | 'improve'
|
||||
|
||||
export interface RefactorParagraphRequest {
|
||||
noteId: string
|
||||
selectedText: string
|
||||
option: RefactorMode
|
||||
}
|
||||
|
||||
export interface RefactorParagraphResponse {
|
||||
originalText: string
|
||||
refactoredText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory Echo Interfaces
|
||||
* STUB - To be implemented in Epic 5 (Story 5-1)
|
||||
*
|
||||
* This feature will analyze all user notes with embeddings to find
|
||||
* connections with cosine similarity > 0.75 and provide proactive insights.
|
||||
*/
|
||||
export interface GenerateMemoryEchoRequest {
|
||||
// No params - uses current user session
|
||||
}
|
||||
|
||||
export interface MemoryEchoInsight {
|
||||
note1Id: string
|
||||
note2Id: string
|
||||
similarityScore: number
|
||||
}
|
||||
|
||||
export interface GenerateMemoryEchoResponse {
|
||||
success: boolean
|
||||
insight: MemoryEchoInsight | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Language Detection Interfaces
|
||||
* @see app/actions/detect-language.ts for implementation
|
||||
*/
|
||||
export interface DetectLanguageRequest {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface DetectLanguageResponse {
|
||||
language: string
|
||||
confidence: number
|
||||
method: 'tinyld' | 'ai'
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Settings Interfaces
|
||||
* @see app/actions/ai-settings.ts for implementation
|
||||
*/
|
||||
export interface AISettingsConfig {
|
||||
titleSuggestions?: boolean
|
||||
semanticSearch?: boolean
|
||||
paragraphRefactor?: boolean
|
||||
memoryEcho?: boolean
|
||||
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider?: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAISettingsRequest {
|
||||
settings: Partial<AISettingsConfig>
|
||||
}
|
||||
|
||||
export interface UpdateAISettingsResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLACEHOLDER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate Title Suggestions
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/title-suggestions.ts
|
||||
*
|
||||
* This function generates 3 AI-powered title suggestions for a note when it
|
||||
* reaches 50+ words without a title.
|
||||
*
|
||||
* @see generateTitleSuggestions in app/actions/title-suggestions.ts
|
||||
*/
|
||||
export async function generateTitles(
|
||||
request: GenerateTitlesRequest
|
||||
): Promise<GenerateTitlesResponse> {
|
||||
// TODO: Import and use implementation from title-suggestions.ts
|
||||
// import { generateTitleSuggestions } from './title-suggestions'
|
||||
// return generateTitleSuggestions(request.noteId)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/title-suggestions.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Search
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/semantic-search.ts
|
||||
*
|
||||
* This function performs hybrid semantic + keyword search across user notes.
|
||||
*
|
||||
* @see semanticSearch in app/actions/semantic-search.ts
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
request: SemanticSearchRequest
|
||||
): Promise<SemanticSearchResponse> {
|
||||
// TODO: Import and use implementation from semantic-search.ts
|
||||
// import { semanticSearch } from './semantic-search'
|
||||
// return semanticSearch(request.query, request.options)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/semantic-search.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor Paragraph
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/paragraph-refactor.ts
|
||||
*
|
||||
* This function refactors a paragraph using AI with specific mode (clarify/shorten/improve).
|
||||
*
|
||||
* @see refactorParagraph in app/actions/paragraph-refactor.ts
|
||||
*/
|
||||
export async function refactorParagraph(
|
||||
request: RefactorParagraphRequest
|
||||
): Promise<RefactorParagraphResponse> {
|
||||
// TODO: Import and use implementation from paragraph-refactor.ts
|
||||
// import { refactorParagraph } from './paragraph-refactor'
|
||||
// return refactorParagraph(request.selectedText, request.option)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/paragraph-refactor.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Memory Echo Insights
|
||||
*
|
||||
* STUB: To be implemented in Epic 5 (Story 5-1)
|
||||
*
|
||||
* This will analyze all user notes with embeddings to find
|
||||
* connections with cosine similarity > 0.75.
|
||||
*
|
||||
* Implementation Plan:
|
||||
* - Fetch all user notes with embeddings
|
||||
* - Calculate pairwise cosine similarities
|
||||
* - Find top connection with similarity > 0.75
|
||||
* - Store in MemoryEchoInsight table
|
||||
* - Return insight or null if none found
|
||||
*
|
||||
* @see Epic 5 Story 5-1 in planning/epics.md
|
||||
*/
|
||||
export async function generateMemoryEcho(): Promise<GenerateMemoryEchoResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Implement Memory Echo background processing
|
||||
// - Fetch all user notes with embeddings from prisma.note
|
||||
// - Calculate pairwise cosine similarities using embedding vectors
|
||||
// - Filter for similarity > 0.75
|
||||
// - Select top insight
|
||||
// - Store in prisma.memoryEchoInsight table (if it exists)
|
||||
// - Return { success: true, insight: {...} }
|
||||
|
||||
throw new Error('Not implemented: See Epic 5 Story 5-1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Language
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/detect-language.ts
|
||||
*
|
||||
* This function detects the language of user content.
|
||||
*
|
||||
* @see getInitialLanguage in app/actions/detect-language.ts
|
||||
*/
|
||||
export async function detectLanguage(
|
||||
request: DetectLanguageRequest
|
||||
): Promise<DetectLanguageResponse> {
|
||||
// TODO: Import and use implementation from detect-language.ts
|
||||
// import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
// const language = await detectUserLanguage()
|
||||
// return { language, confidence: 0.95, method: 'tinyld' }
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/detect-language.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AI Settings
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
|
||||
*
|
||||
* This function updates user AI preferences.
|
||||
*
|
||||
* @see updateAISettings in app/actions/ai-settings.ts
|
||||
*/
|
||||
export async function updateAISettings(
|
||||
request: UpdateAISettingsRequest
|
||||
): Promise<UpdateAISettingsResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Import and use implementation from ai-settings.ts
|
||||
// import { updateAISettings } from './ai-settings'
|
||||
// return updateAISettings(request.settings)
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI Settings
|
||||
*
|
||||
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
|
||||
*
|
||||
* This function retrieves user AI preferences.
|
||||
*
|
||||
* @see getAISettings in app/actions/ai-settings.ts
|
||||
*/
|
||||
export async function getAISettings(): Promise<AISettingsConfig> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// TODO: Import and use implementation from ai-settings.ts
|
||||
// import { getAISettings } from './ai-settings'
|
||||
// return getAISettings()
|
||||
|
||||
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a specific AI feature is enabled for the user
|
||||
*
|
||||
* UTILITY: Helper function to check feature flags
|
||||
*
|
||||
* @param feature - The AI feature to check
|
||||
* @returns Promise<boolean> - Whether the feature is enabled
|
||||
*/
|
||||
export async function isAIFeatureEnabled(
|
||||
feature: keyof AISettingsConfig
|
||||
): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
// Default to enabled for new users
|
||||
return true
|
||||
}
|
||||
|
||||
switch (feature) {
|
||||
case 'titleSuggestions':
|
||||
return settings.titleSuggestions ?? true
|
||||
case 'semanticSearch':
|
||||
return settings.semanticSearch ?? true
|
||||
case 'paragraphRefactor':
|
||||
return settings.paragraphRefactor ?? true
|
||||
case 'memoryEcho':
|
||||
return settings.memoryEcho ?? true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking AI feature enabled:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred AI provider
|
||||
*
|
||||
* UTILITY: Helper function to get provider preference
|
||||
*
|
||||
* @returns Promise<'auto' | 'openai' | 'ollama'> - The AI provider
|
||||
*/
|
||||
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
return (settings?.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama'
|
||||
} catch (error) {
|
||||
console.error('Error getting user AI preference:', error)
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
260
memento-note/app/actions/ai-settings.ts
Normal file
260
memento-note/app/actions/ai-settings.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
semanticSearch?: boolean
|
||||
paragraphRefactor?: boolean
|
||||
memoryEcho?: boolean
|
||||
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
|
||||
aiProvider?: 'auto' | 'openai' | 'ollama'
|
||||
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
demoMode?: boolean
|
||||
showRecentNotes?: boolean
|
||||
notesViewMode?: 'masonry' | 'tabs' | 'list'
|
||||
emailNotifications?: boolean
|
||||
desktopNotifications?: boolean
|
||||
anonymousAnalytics?: boolean
|
||||
fontSize?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
|
||||
const USER_AI_SETTINGS_PRISMA_KEYS = [
|
||||
'titleSuggestions',
|
||||
'semanticSearch',
|
||||
'paragraphRefactor',
|
||||
'memoryEcho',
|
||||
'memoryEchoFrequency',
|
||||
'aiProvider',
|
||||
'preferredLanguage',
|
||||
'fontSize',
|
||||
'demoMode',
|
||||
'showRecentNotes',
|
||||
'notesViewMode',
|
||||
'emailNotifications',
|
||||
'desktopNotifications',
|
||||
'anonymousAnalytics',
|
||||
] as const
|
||||
|
||||
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
|
||||
|
||||
function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<UserAISettingsPrismaKey, unknown>> {
|
||||
const out: Partial<Record<UserAISettingsPrismaKey, unknown>> = {}
|
||||
for (const key of USER_AI_SETTINGS_PRISMA_KEYS) {
|
||||
const v = input[key]
|
||||
if (v !== undefined) {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
if (out.notesViewMode === 'list') {
|
||||
out.notesViewMode = 'tabs'
|
||||
}
|
||||
if (
|
||||
out.notesViewMode != null &&
|
||||
out.notesViewMode !== 'masonry' &&
|
||||
out.notesViewMode !== 'tabs'
|
||||
) {
|
||||
delete out.notesViewMode
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AI settings for the current user
|
||||
*/
|
||||
export async function updateAISettings(settings: UserAISettingsData) {
|
||||
|
||||
const session = await auth()
|
||||
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('[updateAISettings] Unauthorized: No session or user ID')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
const data = pickUserAISettingsForDb(settings)
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Valeurs scalaires uniquement (pickUserAISettingsForDb) — cast pour éviter UpdateOperations vs create.
|
||||
const payload = data as Record<string, string | boolean | undefined>
|
||||
|
||||
// Upsert settings (create if not exists, update if exists)
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
...payload,
|
||||
},
|
||||
update: payload,
|
||||
})
|
||||
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/settings/appearance', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
updateTag('ai-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error updating AI settings:', error)
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const isSchema =
|
||||
/no such column|notesViewMode|Unknown column|does not exist/i.test(raw) ||
|
||||
(typeof raw === 'string' && raw.includes('UserAISettings') && raw.includes('column'))
|
||||
if (isSchema) {
|
||||
throw new Error(
|
||||
'Schéma base de données obsolète : colonne notesViewMode manquante. Dans le dossier memento-note, exécutez : npx prisma db push (ou appliquez les migrations Prisma).'
|
||||
)
|
||||
}
|
||||
throw new Error('Failed to update AI settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AI settings for the current user (Cached)
|
||||
*/
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
// Internal cached function to fetch settings from DB
|
||||
const getCachedAISettings = unstable_cache(
|
||||
async (userId: string) => {
|
||||
try {
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId }
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
notesViewMode: 'masonry' as const,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
|
||||
const raw = settings.notesViewMode
|
||||
const viewMode =
|
||||
raw === 'masonry'
|
||||
? ('masonry' as const)
|
||||
: raw === 'list' || raw === 'tabs'
|
||||
? ('tabs' as const)
|
||||
: ('masonry' as const)
|
||||
|
||||
return {
|
||||
titleSuggestions: settings.titleSuggestions,
|
||||
semanticSearch: settings.semanticSearch,
|
||||
paragraphRefactor: settings.paragraphRefactor,
|
||||
memoryEcho: settings.memoryEcho,
|
||||
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
|
||||
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
|
||||
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
|
||||
demoMode: settings.demoMode,
|
||||
showRecentNotes: settings.showRecentNotes,
|
||||
notesViewMode: viewMode,
|
||||
emailNotifications: settings.emailNotifications,
|
||||
desktopNotifications: settings.desktopNotifications,
|
||||
anonymousAnalytics: settings.anonymousAnalytics,
|
||||
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
|
||||
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting AI settings:', error)
|
||||
// Return defaults on error
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
notesViewMode: 'masonry' as const,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
},
|
||||
['user-ai-settings'],
|
||||
{ tags: ['ai-settings'] }
|
||||
)
|
||||
|
||||
export async function getAISettings(userId?: string) {
|
||||
let id = userId
|
||||
|
||||
if (!id) {
|
||||
const session = await auth()
|
||||
id = session?.user?.id
|
||||
}
|
||||
|
||||
// Return defaults for non-logged-in users
|
||||
if (!id) {
|
||||
return {
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily' as const,
|
||||
aiProvider: 'auto' as const,
|
||||
preferredLanguage: 'auto' as const,
|
||||
demoMode: false,
|
||||
showRecentNotes: false,
|
||||
notesViewMode: 'masonry' as const,
|
||||
emailNotifications: false,
|
||||
desktopNotifications: false,
|
||||
anonymousAnalytics: false,
|
||||
theme: 'light' as const,
|
||||
fontSize: 'medium' as const
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedAISettings(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's preferred AI provider
|
||||
*/
|
||||
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
|
||||
const settings = await getAISettings()
|
||||
return settings.aiProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific AI feature is enabled for the user
|
||||
*/
|
||||
export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise<boolean> {
|
||||
const settings = await getAISettings()
|
||||
|
||||
switch (feature) {
|
||||
case 'titleSuggestions':
|
||||
return settings.titleSuggestions
|
||||
case 'semanticSearch':
|
||||
return settings.semanticSearch
|
||||
case 'paragraphRefactor':
|
||||
return settings.paragraphRefactor
|
||||
case 'memoryEcho':
|
||||
return settings.memoryEcho
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
90
memento-note/app/actions/auth-reset.ts
Normal file
90
memento-note/app/actions/auth-reset.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getEmailTemplate } from '@/lib/email-template'
|
||||
|
||||
// Helper simple pour générer un token sans dépendance externe lourde
|
||||
function generateToken() {
|
||||
const array = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function forgotPassword(email: string) {
|
||||
if (!email) return { error: "Email is required" };
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!user) {
|
||||
// Pour des raisons de sécurité, on ne dit pas si l'email existe ou pas
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const token = generateToken();
|
||||
const expiry = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: expiry
|
||||
}
|
||||
});
|
||||
|
||||
const resetLink = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
||||
|
||||
const html = getEmailTemplate(
|
||||
"Reset your Password",
|
||||
"<p>You requested a password reset for your Memento account.</p><p>Click the button below to set a new password. This link is valid for 1 hour.</p>",
|
||||
resetLink,
|
||||
"Reset Password"
|
||||
);
|
||||
|
||||
const sysConfig = await getSystemConfig()
|
||||
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Memento password",
|
||||
html
|
||||
}, emailProvider);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error);
|
||||
return { error: "Failed to send reset email" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string) {
|
||||
if (!token || !newPassword) return { error: "Missing token or password" };
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { resetToken: token }
|
||||
});
|
||||
|
||||
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
|
||||
return { error: "Invalid or expired token" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error);
|
||||
return { error: "Failed to reset password" };
|
||||
}
|
||||
}
|
||||
29
memento-note/app/actions/auth.ts
Normal file
29
memento-note/app/actions/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use server';
|
||||
|
||||
import { signIn } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
|
||||
export async function authenticate(
|
||||
prevState: string | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
redirectTo: '/',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error('AuthError details:', error.type, error.message);
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid credentials.';
|
||||
default:
|
||||
return `Auth error: ${error.type}`;
|
||||
}
|
||||
}
|
||||
// IMPORTANT: Next.js redirects throw a special error that must be rethrown
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
111
memento-note/app/actions/canvas-actions.ts
Normal file
111
memento-note/app/actions/canvas-actions.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function saveCanvas(id: string | null, name: string, data: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCanvases() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return prisma.canvas.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCanvasDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return prisma.canvas.findUnique({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCanvas(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function renameCanvas(id: string, name: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function createCanvas(lang?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const count = await prisma.canvas.count({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
const defaultNames: Record<string, string> = {
|
||||
en: `Space ${count + 1}`,
|
||||
fr: `Espace ${count + 1}`,
|
||||
fa: `فضای ${count + 1}`,
|
||||
es: `Espacio ${count + 1}`,
|
||||
de: `Bereich ${count + 1}`,
|
||||
it: `Spazio ${count + 1}`,
|
||||
pt: `Espaço ${count + 1}`,
|
||||
ru: `Пространство ${count + 1}`,
|
||||
ja: `スペース ${count + 1}`,
|
||||
ko: `공간 ${count + 1}`,
|
||||
zh: `空间 ${count + 1}`,
|
||||
ar: `مساحة ${count + 1}`,
|
||||
hi: `स्थान ${count + 1}`,
|
||||
nl: `Ruimte ${count + 1}`,
|
||||
pl: `Przestrzeń ${count + 1}`,
|
||||
}
|
||||
|
||||
const newCanvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: defaultNames[lang || 'en'] || defaultNames.en,
|
||||
data: JSON.stringify({}),
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return newCanvas
|
||||
}
|
||||
74
memento-note/app/actions/chat-actions.ts
Normal file
74
memento-note/app/actions/chat-actions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use server'
|
||||
|
||||
import { chatService } from '@/lib/ai/services'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* Create a new empty conversation and return its id.
|
||||
* Called before streaming so the client knows the conversationId upfront.
|
||||
*/
|
||||
export async function createConversation(title: string, notebookId?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
notebookId: notebookId || null,
|
||||
title: title.substring(0, 80) + (title.length > 80 ? '...' : ''),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { id: conversation.id, title: conversation.title }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the streaming API route /api/chat instead.
|
||||
* Kept for backward compatibility with the debug route.
|
||||
*/
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
notebookId?: string
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
try {
|
||||
const result = await chatService.chat(message, conversationId, notebookId)
|
||||
revalidatePath('/chat')
|
||||
return { success: true, ...result }
|
||||
} catch (error: any) {
|
||||
console.error('[ChatAction] Error:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversations() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return chatService.listConversations(session.user.id)
|
||||
}
|
||||
|
||||
export async function getConversationDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return chatService.getHistory(id)
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.conversation.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { success: true }
|
||||
}
|
||||
114
memento-note/app/actions/custom-provider.ts
Normal file
114
memento-note/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,
|
||||
}
|
||||
}
|
||||
12
memento-note/app/actions/detect-language.ts
Normal file
12
memento-note/app/actions/detect-language.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
'use server'
|
||||
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
|
||||
/**
|
||||
* Server action to detect user's preferred language
|
||||
* Called on app load to set initial language
|
||||
*/
|
||||
export async function getInitialLanguage(): Promise<SupportedLanguage> {
|
||||
return await detectUserLanguage()
|
||||
}
|
||||
167
memento-note/app/actions/mcp-keys.ts
Normal file
167
memento-note/app/actions/mcp-keys.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
const KEY_PREFIX = 'mcp_key_'
|
||||
|
||||
function hashKey(rawKey: string): string {
|
||||
return createHash('sha256').update(rawKey).digest('hex')
|
||||
}
|
||||
|
||||
export type McpKeyInfo = {
|
||||
shortId: string
|
||||
name: string
|
||||
userId: string
|
||||
userName: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* List all MCP API keys for the current user.
|
||||
*/
|
||||
export async function listMcpKeys(): Promise<McpKeyInfo[]> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const allKeys = await prisma.systemConfig.findMany({
|
||||
where: { key: { startsWith: KEY_PREFIX } },
|
||||
})
|
||||
|
||||
const keys: McpKeyInfo[] = []
|
||||
for (const entry of allKeys) {
|
||||
try {
|
||||
const info = JSON.parse(entry.value)
|
||||
if (info.userId !== session.user.id) continue
|
||||
keys.push({
|
||||
shortId: info.shortId,
|
||||
name: info.name,
|
||||
userId: info.userId,
|
||||
userName: info.userName,
|
||||
active: info.active,
|
||||
createdAt: info.createdAt,
|
||||
lastUsedAt: info.lastUsedAt,
|
||||
})
|
||||
} catch {
|
||||
// skip invalid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new MCP API key for the current user.
|
||||
* Returns the raw key (shown only once) and key info.
|
||||
*/
|
||||
export async function generateMcpKey(name: string): Promise<{ rawKey: string; info: { shortId: string; name: string } }> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
if (!user) throw new Error('User not found')
|
||||
|
||||
const rawBytes = randomBytes(24)
|
||||
const shortId = rawBytes.toString('hex').substring(0, 8)
|
||||
const rawKey = `mcp_sk_${rawBytes.toString('hex')}`
|
||||
const keyHash = hashKey(rawKey)
|
||||
|
||||
const keyInfo = {
|
||||
shortId,
|
||||
name: name || `Key for ${user.name}`,
|
||||
userId: user.id,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
keyHash,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: null,
|
||||
active: true,
|
||||
}
|
||||
|
||||
await prisma.systemConfig.create({
|
||||
data: {
|
||||
key: `${KEY_PREFIX}${shortId}`,
|
||||
value: JSON.stringify(keyInfo),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/settings/mcp')
|
||||
|
||||
return {
|
||||
rawKey,
|
||||
info: { shortId, name: keyInfo.name },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (deactivate) an MCP API key. Only the owner can revoke.
|
||||
*/
|
||||
export async function revokeMcpKey(shortId: string): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const configKey = `${KEY_PREFIX}${shortId}`
|
||||
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
|
||||
if (!entry) throw new Error('Key not found')
|
||||
|
||||
const info = JSON.parse(entry.value)
|
||||
if (info.userId !== session.user.id) throw new Error('Forbidden')
|
||||
if (!info.active) return false
|
||||
|
||||
info.active = false
|
||||
info.revokedAt = new Date().toISOString()
|
||||
|
||||
await prisma.systemConfig.update({
|
||||
where: { key: configKey },
|
||||
data: { value: JSON.stringify(info) },
|
||||
})
|
||||
|
||||
revalidatePath('/settings/mcp')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an MCP API key. Only the owner can delete.
|
||||
*/
|
||||
export async function deleteMcpKey(shortId: string): Promise<boolean> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const configKey = `${KEY_PREFIX}${shortId}`
|
||||
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
|
||||
if (!entry) throw new Error('Key not found')
|
||||
|
||||
const info = JSON.parse(entry.value)
|
||||
if (info.userId !== session.user.id) throw new Error('Forbidden')
|
||||
|
||||
try {
|
||||
await prisma.systemConfig.delete({ where: { key: configKey } })
|
||||
revalidatePath('/settings/mcp')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type McpServerStatus = {
|
||||
mode: 'stdio' | 'sse' | 'unknown'
|
||||
url: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP server status — mode and URL.
|
||||
*/
|
||||
export async function getMcpServerStatus(): Promise<McpServerStatus> {
|
||||
// Check if SSE mode is configured via env
|
||||
const mode = process.env.MCP_SERVER_MODE === 'sse' ? 'sse' : 'stdio'
|
||||
const url = process.env.MCP_SERVER_URL || null
|
||||
|
||||
return { mode, url }
|
||||
}
|
||||
1748
memento-note/app/actions/notes.ts
Normal file
1748
memento-note/app/actions/notes.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
memento-note/app/actions/ollama.ts
Normal file
57
memento-note/app/actions/ollama.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
'use server'
|
||||
|
||||
interface OllamaModel {
|
||||
name: string
|
||||
modified_at: string
|
||||
size: number
|
||||
digest: string
|
||||
details: {
|
||||
format: string
|
||||
family: string
|
||||
families: string[]
|
||||
parameter_size: string
|
||||
quantization_level: string
|
||||
}
|
||||
}
|
||||
|
||||
interface OllamaTagsResponse {
|
||||
models: OllamaModel[]
|
||||
}
|
||||
|
||||
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
// Ensure URL doesn't end with slash
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${cleanUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Set a reasonable timeout
|
||||
signal: AbortSignal.timeout(5000)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OllamaTagsResponse
|
||||
|
||||
// Extract model names
|
||||
const modelNames = data.models?.map(m => m.name) || []
|
||||
|
||||
return { success: true, models: modelNames }
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch Ollama models:', error)
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
error: error.message || 'Failed to connect to Ollama'
|
||||
}
|
||||
}
|
||||
}
|
||||
49
memento-note/app/actions/paragraph-refactor.ts
Normal file
49
memento-note/app/actions/paragraph-refactor.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
'use server'
|
||||
|
||||
import { paragraphRefactorService, RefactorMode, RefactorResult } from '@/lib/ai/services/paragraph-refactor.service'
|
||||
|
||||
export interface RefactorResponse {
|
||||
result: RefactorResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor a paragraph with a specific mode
|
||||
*/
|
||||
export async function refactorParagraph(
|
||||
content: string,
|
||||
mode: RefactorMode
|
||||
): Promise<RefactorResponse> {
|
||||
try {
|
||||
const result = await paragraphRefactorService.refactor(content, mode)
|
||||
|
||||
return { result }
|
||||
} catch (error) {
|
||||
console.error('Error refactoring paragraph:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 3 refactor options at once
|
||||
*/
|
||||
export async function refactorParagraphAllModes(
|
||||
content: string
|
||||
): Promise<{ results: RefactorResult[] }> {
|
||||
try {
|
||||
const results = await paragraphRefactorService.refactorAllModes(content)
|
||||
|
||||
return { results }
|
||||
} catch (error) {
|
||||
console.error('Error refactoring paragraph in all modes:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate word count before refactoring
|
||||
*/
|
||||
export async function validateRefactorWordCount(
|
||||
content: string
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
return paragraphRefactorService.validateWordCount(content)
|
||||
}
|
||||
232
memento-note/app/actions/profile-broken.ts
Normal file
232
memento-note/app/actions/profile-broken.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Use EXACT same pattern as updateFontSize which works
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
|
||||
try {
|
||||
await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { showRecentNotes: showRecentNotes } as any
|
||||
})
|
||||
} catch (prismaError: any) {
|
||||
// If Prisma client doesn't know about showRecentNotes, use raw SQL
|
||||
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
|
||||
const value = showRecentNotes ? 1 : 0
|
||||
await prisma.$executeRaw`
|
||||
UPDATE UserAISettings
|
||||
SET showRecentNotes = ${value}
|
||||
WHERE userId = ${session.user.id}
|
||||
`
|
||||
} else {
|
||||
throw prismaError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new - same as updateFontSize
|
||||
await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
fontSize: 'medium',
|
||||
showRecentNotes: showRecentNotes
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
232
memento-note/app/actions/profile-old.ts
Normal file
232
memento-note/app/actions/profile-old.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Use EXACT same pattern as updateFontSize which works
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
|
||||
try {
|
||||
await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { showRecentNotes: showRecentNotes } as any
|
||||
})
|
||||
} catch (prismaError: any) {
|
||||
// If Prisma client doesn't know about showRecentNotes, use raw SQL
|
||||
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
|
||||
const value = showRecentNotes ? 1 : 0
|
||||
await prisma.$executeRaw`
|
||||
UPDATE UserAISettings
|
||||
SET showRecentNotes = ${value}
|
||||
WHERE userId = ${session.user.id}
|
||||
`
|
||||
} else {
|
||||
throw prismaError
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new - same as updateFontSize
|
||||
await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
fontSize: 'medium',
|
||||
showRecentNotes: showRecentNotes
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
201
memento-note/app/actions/profile.ts
Normal file
201
memento-note/app/actions/profile.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
|
||||
})
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
|
||||
export async function updateProfile(data: { name: string }) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const validated = ProfileSchema.safeParse(data)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { name: validated.data.name },
|
||||
})
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to update profile'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(formData: FormData) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const rawData = {
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
}
|
||||
|
||||
const validated = PasswordSchema.safeParse(rawData)
|
||||
if (!validated.success) {
|
||||
return { error: validated.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = validated.data
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
})
|
||||
|
||||
if (!user || !user.password) {
|
||||
return { error: { _form: ['User not found'] } }
|
||||
}
|
||||
|
||||
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
|
||||
if (!passwordsMatch) {
|
||||
return { error: { currentPassword: ['Incorrect current password'] } }
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10)
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { password: hashedPassword },
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: { _form: ['Failed to change password'] } }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTheme(theme: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { theme },
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { error: 'Failed to update theme' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Update or create UserAISettings with the preferred language
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
preferredLanguage: language,
|
||||
},
|
||||
update: {
|
||||
preferredLanguage: language,
|
||||
},
|
||||
})
|
||||
|
||||
// Note: The language will be applied on next page load
|
||||
// The client component should handle updating localStorage and reloading
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, language }
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error)
|
||||
return { error: 'Failed to update language' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
// Check if UserAISettings exists
|
||||
const existing = await prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
let result
|
||||
if (existing) {
|
||||
// Update existing - only update fontSize field
|
||||
result = await prisma.userAISettings.update({
|
||||
where: { userId: session.user.id },
|
||||
data: { fontSize: fontSize }
|
||||
})
|
||||
} else {
|
||||
// Create new with all required fields
|
||||
result = await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
fontSize: fontSize,
|
||||
// Set default values for required fields
|
||||
titleSuggestions: true,
|
||||
semanticSearch: true,
|
||||
paragraphRefactor: true,
|
||||
memoryEcho: true,
|
||||
memoryEchoFrequency: 'daily',
|
||||
aiProvider: 'auto',
|
||||
preferredLanguage: 'auto',
|
||||
showRecentNotes: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, fontSize }
|
||||
} catch (error) {
|
||||
console.error('[updateFontSize] Failed to update font size:', error)
|
||||
return { error: 'Failed to update font size' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateShowRecentNotes(showRecentNotes: boolean) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.userAISettings.upsert({
|
||||
where: { userId: session.user.id },
|
||||
create: {
|
||||
userId: session.user.id,
|
||||
showRecentNotes,
|
||||
// Defaults will be used for other fields
|
||||
},
|
||||
update: {
|
||||
showRecentNotes,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/profile')
|
||||
return { success: true, showRecentNotes }
|
||||
} catch (error) {
|
||||
console.error('[updateShowRecentNotes] Failed:', error)
|
||||
return { error: 'Failed to update show recent notes setting' }
|
||||
}
|
||||
}
|
||||
63
memento-note/app/actions/register.ts
Normal file
63
memento-note/app/actions/register.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use server';
|
||||
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().min(2),
|
||||
});
|
||||
|
||||
export async function register(prevState: string | undefined, formData: FormData) {
|
||||
// Check if registration is allowed
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
return 'Registration is currently disabled by the administrator.';
|
||||
}
|
||||
|
||||
const validatedFields = RegisterSchema.safeParse({
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
name: formData.get('name'),
|
||||
});
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return 'Invalid fields. Failed to register.';
|
||||
}
|
||||
|
||||
const { email, password, name } = validatedFields.data;
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (existingUser) {
|
||||
return 'User already exists.';
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt to sign in immediately after registration
|
||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
||||
// but usually it works. If not, redirecting to login is fine.
|
||||
// Let's stick to redirecting to login but with a clear success message?
|
||||
// Or better: lowercase the email to fix the potential bug.
|
||||
} catch (error) {
|
||||
console.error('Registration Error:', error);
|
||||
return 'Database Error: Failed to create user.';
|
||||
}
|
||||
|
||||
redirect('/login');
|
||||
}
|
||||
59
memento-note/app/actions/scrape.ts
Normal file
59
memento-note/app/actions/scrape.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use server'
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface LinkMetadata {
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
|
||||
try {
|
||||
// Add protocol if missing
|
||||
let targetUrl = url;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
targetUrl = 'https://' + url;
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5'
|
||||
},
|
||||
next: { revalidate: 3600 } // Cache for 1 hour
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const getMeta = (prop: string) =>
|
||||
$(`meta[property="${prop}"]`).attr('content') ||
|
||||
$(`meta[name="${prop}"]`).attr('content');
|
||||
|
||||
// Robust extraction with fallbacks
|
||||
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
|
||||
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
|
||||
const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
|
||||
const siteName = getMeta('og:site_name') || '';
|
||||
|
||||
return {
|
||||
url: targetUrl,
|
||||
title: title.substring(0, 100),
|
||||
description: description.substring(0, 200),
|
||||
imageUrl,
|
||||
siteName
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Scrape] Error fetching ${url}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
63
memento-note/app/actions/semantic-search.ts
Normal file
63
memento-note/app/actions/semantic-search.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
'use server'
|
||||
|
||||
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
|
||||
|
||||
export interface SemanticSearchResponse {
|
||||
results: SearchResult[]
|
||||
query: string
|
||||
totalResults: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform hybrid semantic + keyword search
|
||||
* Supports contextual search within notebook (IA5)
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
query: string,
|
||||
options?: {
|
||||
limit?: number
|
||||
threshold?: number
|
||||
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
|
||||
}
|
||||
): Promise<SemanticSearchResponse> {
|
||||
try {
|
||||
const results = await semanticSearchService.search(query, {
|
||||
limit: options?.limit || 20,
|
||||
threshold: options?.threshold || 0.6,
|
||||
notebookId: options?.notebookId // NEW: Pass notebook filter
|
||||
})
|
||||
|
||||
return {
|
||||
results,
|
||||
query,
|
||||
totalResults: results.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in semantic search action:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a note for semantic search (generate embedding)
|
||||
*/
|
||||
export async function indexNote(noteId: string): Promise<void> {
|
||||
try {
|
||||
await semanticSearchService.indexNote(noteId)
|
||||
} catch (error) {
|
||||
console.error('Error indexing note:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch index notes (for initial setup)
|
||||
*/
|
||||
export async function batchIndexNotes(noteIds: string[]): Promise<void> {
|
||||
try {
|
||||
await semanticSearchService.indexBatchNotes(noteIds)
|
||||
} catch (error) {
|
||||
console.error('Error batch indexing notes:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
128
memento-note/app/actions/title-suggestions.ts
Normal file
128
memento-note/app/actions/title-suggestions.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export interface GenerateTitlesResponse {
|
||||
suggestions: Array<{
|
||||
title: string
|
||||
confidence: number
|
||||
reasoning?: string
|
||||
}>
|
||||
noteId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate title suggestions for a note
|
||||
* Triggered when note reaches 50+ words without a title
|
||||
*/
|
||||
export async function generateTitleSuggestions(noteId: string): Promise<GenerateTitlesResponse> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch note content
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { id: true, content: true, userId: true }
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('Note not found')
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
throw new Error('Forbidden')
|
||||
}
|
||||
|
||||
if (!note.content || note.content.trim().length === 0) {
|
||||
throw new Error('Note content is empty')
|
||||
}
|
||||
|
||||
// Generate suggestions
|
||||
const suggestions = await titleSuggestionService.generateSuggestions(note.content)
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
noteId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating title suggestions:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply selected title to note
|
||||
*/
|
||||
export async function applyTitleSuggestion(
|
||||
noteId: string,
|
||||
selectedTitle: string
|
||||
): Promise<void> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Update note with selected title
|
||||
await prisma.note.update({
|
||||
where: {
|
||||
id: noteId,
|
||||
userId: session.user.id
|
||||
},
|
||||
data: {
|
||||
title: selectedTitle,
|
||||
autoGenerated: true,
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath(`/note/${noteId}`)
|
||||
} catch (error) {
|
||||
console.error('Error applying title suggestion:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record user feedback on title suggestions
|
||||
* (Phase 3 - for improving future suggestions)
|
||||
*/
|
||||
export async function recordTitleFeedback(
|
||||
noteId: string,
|
||||
selectedTitle: string,
|
||||
allSuggestions: Array<{ title: string; confidence: number }>
|
||||
): Promise<void> {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Save to AiFeedback table for learning
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId,
|
||||
userId: session.user.id,
|
||||
feedbackType: 'thumbs_up', // User chose one of our suggestions
|
||||
feature: 'title_suggestion',
|
||||
originalContent: JSON.stringify(allSuggestions),
|
||||
correctedContent: selectedTitle,
|
||||
metadata: JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: 'auto' // Will be dynamic based on user settings
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error recording title feedback:', error)
|
||||
// Don't throw - feedback is optional
|
||||
}
|
||||
}
|
||||
86
memento-note/app/actions/user-settings.ts
Normal file
86
memento-note/app/actions/user-settings.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
cardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user settings (theme, etc.)
|
||||
*/
|
||||
export async function updateUserSettings(settings: UserSettingsData) {
|
||||
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
console.error('[updateUserSettings] Unauthorized')
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: settings
|
||||
})
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
updateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error updating user settings:', error)
|
||||
throw new Error('Failed to update user settings')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user settings for current user (Cached)
|
||||
*/
|
||||
import { unstable_cache } from 'next/cache'
|
||||
|
||||
// Internal cached function
|
||||
const getCachedUserSettings = unstable_cache(
|
||||
async (userId: string) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { theme: true, cardSizeMode: true }
|
||||
})
|
||||
|
||||
return {
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
|
||||
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error)
|
||||
return {
|
||||
theme: 'light' as const
|
||||
}
|
||||
}
|
||||
},
|
||||
['user-settings'],
|
||||
{ tags: ['user-settings'] }
|
||||
)
|
||||
|
||||
export async function getUserSettings(userId?: string) {
|
||||
let id = userId
|
||||
|
||||
if (!id) {
|
||||
const session = await auth()
|
||||
id = session?.user?.id
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
theme: 'light' as const,
|
||||
cardSizeMode: 'variable' as const
|
||||
}
|
||||
}
|
||||
|
||||
return getCachedUserSettings(id)
|
||||
}
|
||||
Reference in New Issue
Block a user