mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: full api server
This commit is contained in:
@@ -1,5 +1,125 @@
|
||||
import Elysia from 'elysia'
|
||||
import { bearer } from '@elysiajs/bearer'
|
||||
import { jwt } from '@elysiajs/jwt'
|
||||
import { AgentStreamModel } from './model'
|
||||
import { createAgentStream } from './service'
|
||||
import { getChatModel, getEmbeddingModel, getSummaryModel } from '../model/service'
|
||||
import { getSettings } from '../settings/service'
|
||||
import { ChatModel, EmbeddingModel } from '@memohome/shared'
|
||||
|
||||
export const agentModule = new Elysia({
|
||||
prefix: '/agent',
|
||||
})
|
||||
})
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
exp: process.env.JWT_EXPIRES_IN || '7d',
|
||||
})
|
||||
)
|
||||
.use(bearer())
|
||||
.derive(async ({ bearer, jwt, set }) => {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
throw new Error('No bearer token provided')
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
throw new Error('Invalid or expired token')
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: payload.userId as string,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
},
|
||||
}
|
||||
})
|
||||
// Stream agent conversation
|
||||
.post('/stream', async ({ user, body, set }) => {
|
||||
try {
|
||||
// Get user's model configurations and settings
|
||||
const [chatModel, embeddingModel, summaryModel, userSettings] = await Promise.all([
|
||||
getChatModel(user.userId),
|
||||
getEmbeddingModel(user.userId),
|
||||
getSummaryModel(user.userId),
|
||||
getSettings(user.userId),
|
||||
])
|
||||
|
||||
if (!chatModel || !embeddingModel || !summaryModel) {
|
||||
set.status = 400
|
||||
return {
|
||||
success: false,
|
||||
error: 'Model configuration not found. Please configure your models in settings.',
|
||||
}
|
||||
}
|
||||
|
||||
// Use body params if provided, otherwise use settings, otherwise use defaults
|
||||
const maxContextLoadTime = body.maxContextLoadTime
|
||||
?? userSettings?.maxContextLoadTime
|
||||
?? 60
|
||||
const language = body.language
|
||||
?? userSettings?.language
|
||||
?? 'Same as user input'
|
||||
|
||||
// Create agent
|
||||
const agent = await createAgentStream({
|
||||
userId: user.userId,
|
||||
chatModel: chatModel.model as ChatModel,
|
||||
embeddingModel: embeddingModel.model as EmbeddingModel,
|
||||
summaryModel: summaryModel.model as ChatModel,
|
||||
maxContextLoadTime,
|
||||
language,
|
||||
})
|
||||
|
||||
// Set headers for Server-Sent Events
|
||||
set.headers['Content-Type'] = 'text/event-stream'
|
||||
set.headers['Cache-Control'] = 'no-cache'
|
||||
set.headers['Connection'] = 'keep-alive'
|
||||
|
||||
// Create a stream
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// Send events as they come
|
||||
for await (const event of agent.ask(body.message)) {
|
||||
const data = JSON.stringify(event)
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
|
||||
// Send done event
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
const errorData = JSON.stringify({
|
||||
type: 'error',
|
||||
error: errorMessage
|
||||
})
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${errorData}\n\n`))
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to process request',
|
||||
}
|
||||
}
|
||||
}, AgentStreamModel)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const AgentStreamModel = {
|
||||
body: z.object({
|
||||
message: z.string().min(1, 'Message is required'),
|
||||
// Optional overrides - if not provided, will use settings
|
||||
maxContextLoadTime: z.number().int().min(1).max(1440).optional(),
|
||||
language: z.string().optional(),
|
||||
}),
|
||||
}
|
||||
|
||||
export type AgentStreamInput = z.infer<typeof AgentStreamModel['body']>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createAgent } from '@memohome/agent'
|
||||
import { createMemory, filterByTimestamp, MemoryUnit } from '@memohome/memory'
|
||||
import { ChatModel, EmbeddingModel } from '@memohome/shared'
|
||||
|
||||
// Type for messages passed to onFinish callback
|
||||
type MessageType = Record<string, unknown>
|
||||
|
||||
export interface CreateAgentStreamParams {
|
||||
userId: string
|
||||
chatModel: ChatModel
|
||||
embeddingModel: EmbeddingModel
|
||||
summaryModel: ChatModel
|
||||
maxContextLoadTime: number
|
||||
language?: string
|
||||
onFinish?: (messages: MessageType[]) => Promise<void>
|
||||
}
|
||||
|
||||
export async function createAgentStream(params: CreateAgentStreamParams) {
|
||||
const {
|
||||
userId,
|
||||
chatModel,
|
||||
embeddingModel,
|
||||
summaryModel,
|
||||
maxContextLoadTime,
|
||||
language,
|
||||
onFinish,
|
||||
} = params
|
||||
|
||||
// Create memory instance
|
||||
const memoryInstance = createMemory({
|
||||
summaryModel,
|
||||
embeddingModel,
|
||||
})
|
||||
|
||||
// Create agent
|
||||
const agent = createAgent({
|
||||
model: chatModel,
|
||||
maxContextLoadTime,
|
||||
language: language || 'Same as user input',
|
||||
onReadMemory: async (from: Date, to: Date) => {
|
||||
return await filterByTimestamp(from, to, userId)
|
||||
},
|
||||
onSearchMemory: async (query: string) => {
|
||||
const results = await memoryInstance.searchMemory(query, userId)
|
||||
return results
|
||||
},
|
||||
onFinish: async (messages: MessageType[]) => {
|
||||
// Save conversation to memory
|
||||
const memoryUnit: MemoryUnit = {
|
||||
messages: messages as unknown as MemoryUnit['messages'],
|
||||
timestamp: new Date(),
|
||||
user: userId,
|
||||
}
|
||||
await memoryInstance.addMemory(memoryUnit)
|
||||
|
||||
// Call custom onFinish handler if provided
|
||||
await onFinish?.(messages)
|
||||
},
|
||||
})
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import Elysia from 'elysia'
|
||||
import { bearer } from '@elysiajs/bearer'
|
||||
import { jwt } from '@elysiajs/jwt'
|
||||
import { LoginModel } from './model'
|
||||
import { validateUser } from './service'
|
||||
|
||||
export const authModule = new Elysia({
|
||||
prefix: '/auth',
|
||||
})
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
exp: process.env.JWT_EXPIRES_IN || '7d',
|
||||
})
|
||||
)
|
||||
.use(bearer())
|
||||
// Login endpoint
|
||||
.post('/login', async ({ body, jwt, set }) => {
|
||||
try {
|
||||
const user = await validateUser(body.username, body.password)
|
||||
|
||||
if (!user) {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid username or password',
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 JWT 插件生成 token
|
||||
const token = await jwt.sign({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to login',
|
||||
}
|
||||
}
|
||||
}, LoginModel)
|
||||
// Verify token endpoint
|
||||
.get('/verify', async ({ bearer, jwt, set }) => {
|
||||
try {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'No bearer token provided',
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 JWT 插件验证 token
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
}
|
||||
}
|
||||
})
|
||||
// Get current user info
|
||||
.get('/me', async ({ bearer, jwt, set }) => {
|
||||
try {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'No bearer token provided',
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 JWT 插件验证 token
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
set.status = 401
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid or expired token',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
})
|
||||
|
||||
export type LoginInput = z.infer<typeof LoginSchema>
|
||||
|
||||
export const LoginModel = {
|
||||
body: LoginSchema,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { db } from '@memohome/db'
|
||||
import { users, settings } from '@memohome/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 验证用户凭据
|
||||
* 优先检查是否为 ROOT 用户,否则查询数据库
|
||||
*/
|
||||
export const validateUser = async (username: string, password: string) => {
|
||||
// 检查是否为 ROOT 用户
|
||||
const rootUser = process.env.ROOT_USER
|
||||
const rootPassword = process.env.ROOT_USER_PASSWORD
|
||||
|
||||
if (rootUser && rootPassword && username === rootUser) {
|
||||
if (password === rootPassword) {
|
||||
// 检查 root 用户的 settings 是否存在,不存在则创建
|
||||
const [existingSettings] = await db
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.userId, 'root'))
|
||||
|
||||
if (!existingSettings) {
|
||||
// 为 root 用户创建默认 settings
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
userId: 'root',
|
||||
defaultChatModel: null,
|
||||
defaultEmbeddingModel: null,
|
||||
defaultSummaryModel: null,
|
||||
maxContextLoadTime: 60,
|
||||
language: 'Same as user input',
|
||||
})
|
||||
.onConflictDoNothing() // 避免并发创建导致的冲突
|
||||
}
|
||||
|
||||
// 返回 ROOT 用户信息
|
||||
return {
|
||||
id: 'root',
|
||||
username: rootUser,
|
||||
role: 'admin' as const,
|
||||
displayName: 'Root User',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 查询数据库中的用户
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 验证密码 (这里使用简单的 Bun.password.verify)
|
||||
const isValid = await Bun.password.verify(password, user.passwordHash)
|
||||
|
||||
if (!isValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查账户是否激活
|
||||
if (user.isActive !== 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
lastLoginAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
displayName: user.displayName || user.username,
|
||||
email: user.email,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './agent'
|
||||
export * from './auth'
|
||||
export * from './model'
|
||||
export * from './settings'
|
||||
export * from './settings'
|
||||
export * from './user'
|
||||
@@ -1,4 +1,6 @@
|
||||
import Elysia from 'elysia'
|
||||
import { bearer } from '@elysiajs/bearer'
|
||||
import { jwt } from '@elysiajs/jwt'
|
||||
import { messageModule } from './message'
|
||||
import { AddMemoryModel, SearchMemoryModel } from './model'
|
||||
import { addMemory, searchMemory } from './service'
|
||||
@@ -7,31 +9,66 @@ import { MemoryUnit } from '@memohome/memory'
|
||||
export const memoryModule = new Elysia({
|
||||
prefix: '/memory',
|
||||
})
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
exp: process.env.JWT_EXPIRES_IN || '7d',
|
||||
})
|
||||
)
|
||||
.use(bearer())
|
||||
.derive(async ({ bearer, jwt, set }) => {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
throw new Error('No bearer token provided')
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
throw new Error('Invalid or expired token')
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: payload.userId as string,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
},
|
||||
}
|
||||
})
|
||||
.use(messageModule)
|
||||
// Add memory
|
||||
.post('/', async ({ body }) => {
|
||||
// Add memory for current user
|
||||
.post('/', async ({ user, body, set }) => {
|
||||
try {
|
||||
const result = await addMemory(body as unknown as MemoryUnit)
|
||||
const memoryUnit: MemoryUnit = {
|
||||
...body,
|
||||
user: user.userId,
|
||||
}
|
||||
const result = await addMemory(memoryUnit)
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to add memory',
|
||||
}
|
||||
}
|
||||
}, AddMemoryModel)
|
||||
// Search memory
|
||||
.get('/search', async ({ query }) => {
|
||||
// Search memory for current user
|
||||
.get('/search', async ({ user, query, set }) => {
|
||||
try {
|
||||
const results = await searchMemory(query.query, query.userId)
|
||||
const results = await searchMemory(query.query, user.userId)
|
||||
return {
|
||||
success: true,
|
||||
data: results,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to search memory',
|
||||
|
||||
@@ -1,22 +1,70 @@
|
||||
import Elysia from 'elysia'
|
||||
import { bearer } from '@elysiajs/bearer'
|
||||
import { jwt } from '@elysiajs/jwt'
|
||||
import { GetMemoryMessageFilterModel, GetMemoryMessageModel } from './model'
|
||||
import { getMemoryMessages, getMemoryMessagesFilter } from './service'
|
||||
|
||||
export const messageModule = new Elysia({
|
||||
prefix: '/message',
|
||||
})
|
||||
.get('/', async ({ query }) => {
|
||||
const units = await getMemoryMessages(query)
|
||||
return {
|
||||
success: true,
|
||||
units,
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
exp: process.env.JWT_EXPIRES_IN || '7d',
|
||||
})
|
||||
)
|
||||
.use(bearer())
|
||||
.derive(async ({ bearer, jwt, set }) => {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
throw new Error('No bearer token provided')
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
throw new Error('Invalid or expired token')
|
||||
}
|
||||
}, GetMemoryMessageModel)
|
||||
.get('/filter', async ({ query }) => {
|
||||
const units = await getMemoryMessagesFilter(query)
|
||||
|
||||
return {
|
||||
units,
|
||||
success: true,
|
||||
user: {
|
||||
userId: payload.userId as string,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
},
|
||||
}
|
||||
})
|
||||
// Get messages for current user (paginated)
|
||||
.get('/', async ({ user, query, set }) => {
|
||||
try {
|
||||
const units = await getMemoryMessages(user.userId, query)
|
||||
return {
|
||||
success: true,
|
||||
data: units,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch messages',
|
||||
}
|
||||
}
|
||||
}, GetMemoryMessageModel)
|
||||
// Get messages by date range for current user
|
||||
.get('/filter', async ({ user, query, set }) => {
|
||||
try {
|
||||
const units = await getMemoryMessagesFilter(user.userId, query)
|
||||
return {
|
||||
success: true,
|
||||
data: units,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to filter messages',
|
||||
}
|
||||
}
|
||||
}, GetMemoryMessageFilterModel)
|
||||
@@ -2,16 +2,14 @@ import { z } from 'zod'
|
||||
|
||||
export const GetMemoryMessageModel = {
|
||||
query: z.object({
|
||||
limit: z.string().transform(Number).default(10),
|
||||
page: z.string().transform(Number).default(1),
|
||||
userId: z.string(),
|
||||
limit: z.coerce.number().default(10),
|
||||
page: z.coerce.number().default(1),
|
||||
}),
|
||||
}
|
||||
|
||||
export const GetMemoryMessageFilterModel = {
|
||||
query: z.object({
|
||||
from: z.date(),
|
||||
to: z.date(),
|
||||
userId: z.string(),
|
||||
from: z.coerce.date(),
|
||||
to: z.coerce.date(),
|
||||
}),
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { history } from '@memohome/db/schema'
|
||||
import { eq, desc, and, gte, lte, asc } from 'drizzle-orm'
|
||||
|
||||
export const getMemoryMessages = async (
|
||||
userId: string,
|
||||
query: {
|
||||
limit: number
|
||||
page: number
|
||||
userId: string
|
||||
}
|
||||
) => {
|
||||
const { limit, page, userId } = query
|
||||
const { limit, page } = query
|
||||
const results = await db
|
||||
.select()
|
||||
.from(history)
|
||||
@@ -22,13 +22,13 @@ export const getMemoryMessages = async (
|
||||
}
|
||||
|
||||
export const getMemoryMessagesFilter = async (
|
||||
userId: string,
|
||||
query: {
|
||||
from: Date
|
||||
to: Date
|
||||
userId: string
|
||||
}
|
||||
) => {
|
||||
const { from, to, userId } = query
|
||||
const { from, to } = query
|
||||
const results = await db
|
||||
.select()
|
||||
.from(history)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// MemoryUnit schema
|
||||
const MemoryUnitSchema = z.object({
|
||||
messages: z.array(z.object()),
|
||||
// MemoryUnit schema (without user field, will be added from auth)
|
||||
const MemoryUnitBodySchema = z.object({
|
||||
messages: z.array(z.any()),
|
||||
timestamp: z.coerce.date(),
|
||||
user: z.string(),
|
||||
})
|
||||
|
||||
export const AddMemoryModel = {
|
||||
body: MemoryUnitSchema,
|
||||
body: MemoryUnitBodySchema,
|
||||
}
|
||||
|
||||
export const SearchMemoryModel = {
|
||||
query: z.object({
|
||||
query: z.string().min(1, 'Search query is required'),
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
import Elysia from 'elysia'
|
||||
import {
|
||||
GetSettingsModel,
|
||||
CreateSettingsModel,
|
||||
UpdateSettingsModel,
|
||||
} from './model'
|
||||
import {
|
||||
getSettings,
|
||||
createSettings,
|
||||
updateSettings,
|
||||
upsertSettings,
|
||||
} from './service'
|
||||
import { bearer } from '@elysiajs/bearer'
|
||||
import { jwt } from '@elysiajs/jwt'
|
||||
import { UpdateSettingsModel } from './model'
|
||||
import { getSettings, upsertSettings } from './service'
|
||||
|
||||
export const settingsModule = new Elysia({
|
||||
prefix: '/settings',
|
||||
})
|
||||
// Get user settings
|
||||
.get('/:userId', async ({ params }) => {
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
exp: process.env.JWT_EXPIRES_IN || '7d',
|
||||
})
|
||||
)
|
||||
.use(bearer())
|
||||
.derive(async ({ bearer, jwt, set }) => {
|
||||
if (!bearer) {
|
||||
set.status = 401
|
||||
throw new Error('No bearer token provided')
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(bearer)
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401
|
||||
throw new Error('Invalid or expired token')
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
userId: payload.userId as string,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
},
|
||||
}
|
||||
})
|
||||
// Get current user's settings
|
||||
.get('/', async ({ user, set }) => {
|
||||
try {
|
||||
const { userId } = params
|
||||
const userSettings = await getSettings(userId)
|
||||
const userSettings = await getSettings(user.userId)
|
||||
if (!userSettings) {
|
||||
set.status = 404
|
||||
return {
|
||||
success: false,
|
||||
error: 'Settings not found',
|
||||
@@ -30,62 +52,27 @@ export const settingsModule = new Elysia({
|
||||
data: userSettings,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch settings',
|
||||
}
|
||||
}
|
||||
}, GetSettingsModel)
|
||||
// Create new settings
|
||||
.post('/', async ({ body }) => {
|
||||
})
|
||||
// Update or create current user's settings
|
||||
.put('/', async ({ user, body, set }) => {
|
||||
try {
|
||||
const newSettings = await createSettings(body)
|
||||
const result = await upsertSettings(user.userId, body)
|
||||
return {
|
||||
success: true,
|
||||
data: newSettings,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create settings',
|
||||
}
|
||||
}
|
||||
}, CreateSettingsModel)
|
||||
// Update settings
|
||||
.put('/:userId', async ({ params, body }) => {
|
||||
try {
|
||||
const { userId } = params
|
||||
const updatedSettings = await updateSettings(userId, body)
|
||||
if (!updatedSettings) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Settings not found',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: updatedSettings,
|
||||
data: result,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update settings',
|
||||
}
|
||||
}
|
||||
}, UpdateSettingsModel)
|
||||
// Upsert settings (create or update)
|
||||
.patch('/', async ({ body }) => {
|
||||
try {
|
||||
const result = await upsertSettings(body)
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to upsert settings',
|
||||
}
|
||||
}
|
||||
}, CreateSettingsModel)
|
||||
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const SettingsSchema = z.object({
|
||||
userId: z.string().min(1, 'User ID is required'),
|
||||
const SettingsBodySchema = z.object({
|
||||
defaultChatModel: z.string().uuid().nullable().optional(),
|
||||
defaultEmbeddingModel: z.string().uuid().nullable().optional(),
|
||||
defaultSummaryModel: z.string().uuid().nullable().optional(),
|
||||
maxContextLoadTime: z.number().int().min(1).max(1440).optional(), // 1 minute to 24 hours
|
||||
language: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SettingsInput = z.infer<typeof SettingsSchema>
|
||||
|
||||
export const GetSettingsModel = {
|
||||
params: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
}
|
||||
|
||||
export const CreateSettingsModel = {
|
||||
body: SettingsSchema,
|
||||
}
|
||||
export type SettingsInput = z.infer<typeof SettingsBodySchema>
|
||||
|
||||
export const UpdateSettingsModel = {
|
||||
params: z.object({
|
||||
userId: z.string(),
|
||||
}),
|
||||
body: z.object({
|
||||
defaultChatModel: z.string().uuid().nullable().optional(),
|
||||
defaultEmbeddingModel: z.string().uuid().nullable().optional(),
|
||||
defaultSummaryModel: z.string().uuid().nullable().optional(),
|
||||
}),
|
||||
body: SettingsBodySchema,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,51 +11,38 @@ export const getSettings = async (userId: string) => {
|
||||
return result
|
||||
}
|
||||
|
||||
export const createSettings = async (data: SettingsInput) => {
|
||||
const [newSettings] = await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
userId: data.userId,
|
||||
defaultChatModel: data.defaultChatModel || null,
|
||||
defaultEmbeddingModel: data.defaultEmbeddingModel || null,
|
||||
defaultSummaryModel: data.defaultSummaryModel || null,
|
||||
})
|
||||
.returning()
|
||||
return newSettings
|
||||
}
|
||||
export const upsertSettings = async (userId: string, data: SettingsInput) => {
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (data.defaultChatModel !== undefined) {
|
||||
updateData.defaultChatModel = data.defaultChatModel
|
||||
}
|
||||
if (data.defaultEmbeddingModel !== undefined) {
|
||||
updateData.defaultEmbeddingModel = data.defaultEmbeddingModel
|
||||
}
|
||||
if (data.defaultSummaryModel !== undefined) {
|
||||
updateData.defaultSummaryModel = data.defaultSummaryModel
|
||||
}
|
||||
if (data.maxContextLoadTime !== undefined) {
|
||||
updateData.maxContextLoadTime = data.maxContextLoadTime
|
||||
}
|
||||
if (data.language !== undefined) {
|
||||
updateData.language = data.language
|
||||
}
|
||||
|
||||
export const updateSettings = async (
|
||||
userId: string,
|
||||
data: Partial<Omit<SettingsInput, 'userId'>>
|
||||
) => {
|
||||
const [updatedSettings] = await db
|
||||
.update(settings)
|
||||
.set({
|
||||
defaultChatModel: data.defaultChatModel,
|
||||
defaultEmbeddingModel: data.defaultEmbeddingModel,
|
||||
defaultSummaryModel: data.defaultSummaryModel,
|
||||
})
|
||||
.where(eq(settings.userId, userId))
|
||||
.returning()
|
||||
return updatedSettings
|
||||
}
|
||||
|
||||
export const upsertSettings = async (data: SettingsInput) => {
|
||||
const [result] = await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
userId: data.userId,
|
||||
userId: userId,
|
||||
defaultChatModel: data.defaultChatModel || null,
|
||||
defaultEmbeddingModel: data.defaultEmbeddingModel || null,
|
||||
defaultSummaryModel: data.defaultSummaryModel || null,
|
||||
maxContextLoadTime: data.maxContextLoadTime || 60,
|
||||
language: data.language || 'Same as user input',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: settings.userId,
|
||||
set: {
|
||||
defaultChatModel: data.defaultChatModel,
|
||||
defaultEmbeddingModel: data.defaultEmbeddingModel,
|
||||
defaultSummaryModel: data.defaultSummaryModel,
|
||||
},
|
||||
set: updateData,
|
||||
})
|
||||
.returning()
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import Elysia from 'elysia'
|
||||
import { adminMiddleware } from '../../middlewares'
|
||||
import {
|
||||
GetUserByIdModel,
|
||||
CreateUserModel,
|
||||
UpdateUserModel,
|
||||
DeleteUserModel,
|
||||
UpdatePasswordModel,
|
||||
} from './model'
|
||||
import {
|
||||
getUsers,
|
||||
getUserById,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
updateUserPassword,
|
||||
} from './service'
|
||||
|
||||
export const userModule = new Elysia({
|
||||
prefix: '/user',
|
||||
})
|
||||
// 使用管理员中间件保护所有路由
|
||||
.use(adminMiddleware)
|
||||
// Get all users
|
||||
.get('/', async () => {
|
||||
try {
|
||||
const userList = await getUsers()
|
||||
return {
|
||||
success: true,
|
||||
data: userList,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch users',
|
||||
}
|
||||
}
|
||||
})
|
||||
// Get user by ID
|
||||
.get('/:id', async ({ params, set }) => {
|
||||
try {
|
||||
const { id } = params
|
||||
const user = await getUserById(id)
|
||||
|
||||
if (!user) {
|
||||
set.status = 404
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: user,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch user',
|
||||
}
|
||||
}
|
||||
}, GetUserByIdModel)
|
||||
// Create new user
|
||||
.post('/', async ({ body, set }) => {
|
||||
try {
|
||||
const newUser = await createUser(body)
|
||||
set.status = 201
|
||||
return {
|
||||
success: true,
|
||||
data: newUser,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (
|
||||
error.message.includes('already exists')
|
||||
)) {
|
||||
set.status = 409
|
||||
} else {
|
||||
set.status = 500
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create user',
|
||||
}
|
||||
}
|
||||
}, CreateUserModel)
|
||||
// Update user
|
||||
.put('/:id', async ({ params, body, set }) => {
|
||||
try {
|
||||
const { id } = params
|
||||
const updatedUser = await updateUser(id, body)
|
||||
|
||||
if (!updatedUser) {
|
||||
set.status = 404
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedUser,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
set.status = 409
|
||||
} else {
|
||||
set.status = 500
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update user',
|
||||
}
|
||||
}
|
||||
}, UpdateUserModel)
|
||||
// Delete user
|
||||
.delete('/:id', async ({ params, set }) => {
|
||||
try {
|
||||
const { id } = params
|
||||
const deletedUser = await deleteUser(id)
|
||||
|
||||
if (!deletedUser) {
|
||||
set.status = 404
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: deletedUser,
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete user',
|
||||
}
|
||||
}
|
||||
}, DeleteUserModel)
|
||||
// Update user password
|
||||
.patch('/:id/password', async ({ params, body, set }) => {
|
||||
try {
|
||||
const { id } = params
|
||||
const updatedUser = await updateUserPassword(id, body.password)
|
||||
|
||||
if (!updatedUser) {
|
||||
set.status = 404
|
||||
return {
|
||||
success: false,
|
||||
error: 'User not found',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: updatedUser,
|
||||
message: 'Password updated successfully',
|
||||
}
|
||||
} catch (error) {
|
||||
set.status = 500
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update password',
|
||||
}
|
||||
}
|
||||
}, UpdatePasswordModel)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// 用户角色枚举
|
||||
const UserRoleSchema = z.enum(['admin', 'member'])
|
||||
|
||||
// 创建用户的 Schema
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(3, 'Username must be at least 3 characters').max(50),
|
||||
email: z.string().email('Invalid email format').optional(),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
role: UserRoleSchema.default('member'),
|
||||
displayName: z.string().optional(),
|
||||
avatarUrl: z.string().url('Invalid URL format').optional(),
|
||||
})
|
||||
|
||||
// 更新用户的 Schema
|
||||
const UpdateUserSchema = z.object({
|
||||
email: z.string().email('Invalid email format').optional(),
|
||||
role: UserRoleSchema.optional(),
|
||||
displayName: z.string().optional(),
|
||||
avatarUrl: z.string().url('Invalid URL format').optional(),
|
||||
isActive: z.enum(['true', 'false']).optional(),
|
||||
})
|
||||
|
||||
// 更新密码的 Schema
|
||||
const UpdatePasswordSchema = z.object({
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
})
|
||||
|
||||
export type CreateUserInput = z.infer<typeof CreateUserSchema>
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>
|
||||
export type UpdatePasswordInput = z.infer<typeof UpdatePasswordSchema>
|
||||
|
||||
export const GetUserByIdModel = {
|
||||
params: z.object({
|
||||
id: z.string().uuid('Invalid user ID format'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const CreateUserModel = {
|
||||
body: CreateUserSchema,
|
||||
}
|
||||
|
||||
export const UpdateUserModel = {
|
||||
params: z.object({
|
||||
id: z.string().uuid('Invalid user ID format'),
|
||||
}),
|
||||
body: UpdateUserSchema,
|
||||
}
|
||||
|
||||
export const DeleteUserModel = {
|
||||
params: z.object({
|
||||
id: z.string().uuid('Invalid user ID format'),
|
||||
}),
|
||||
}
|
||||
|
||||
export const UpdatePasswordModel = {
|
||||
params: z.object({
|
||||
id: z.string().uuid('Invalid user ID format'),
|
||||
}),
|
||||
body: UpdatePasswordSchema,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { db } from '@memohome/db'
|
||||
import { users, settings } from '@memohome/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { CreateUserInput, UpdateUserInput } from './model'
|
||||
|
||||
/**
|
||||
* 获取所有用户列表
|
||||
*/
|
||||
export const getUsers = async () => {
|
||||
const userList = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isActive: users.isActive,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
lastLoginAt: users.lastLoginAt,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(users.createdAt)
|
||||
|
||||
return userList
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取用户
|
||||
*/
|
||||
export const getUserById = async (id: string) => {
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isActive: users.isActive,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
lastLoginAt: users.lastLoginAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新用户
|
||||
*/
|
||||
export const createUser = async (data: CreateUserInput) => {
|
||||
// 检查用户名是否已存在
|
||||
const [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, data.username))
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error('Username already exists')
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在(如果提供了邮箱)
|
||||
if (data.email) {
|
||||
const [existingEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, data.email))
|
||||
|
||||
if (existingEmail) {
|
||||
throw new Error('Email already exists')
|
||||
}
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
const passwordHash = await Bun.password.hash(data.password)
|
||||
|
||||
// 创建用户
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: data.username,
|
||||
email: data.email || null,
|
||||
passwordHash,
|
||||
role: data.role || 'member',
|
||||
displayName: data.displayName || null,
|
||||
avatarUrl: data.avatarUrl || null,
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isActive: users.isActive,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
|
||||
// 自动创建用户的 settings 条目(使用默认值)
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
userId: newUser.id,
|
||||
defaultChatModel: null,
|
||||
defaultEmbeddingModel: null,
|
||||
defaultSummaryModel: null,
|
||||
maxContextLoadTime: 60,
|
||||
language: 'Same as user input',
|
||||
})
|
||||
|
||||
return newUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息
|
||||
*/
|
||||
export const updateUser = async (id: string, data: UpdateUserInput) => {
|
||||
// 检查用户是否存在
|
||||
const existingUser = await getUserById(id)
|
||||
if (!existingUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果更新邮箱,检查邮箱是否已被其他用户使用
|
||||
if (data.email) {
|
||||
const [emailUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, data.email))
|
||||
|
||||
if (emailUser && emailUser.id !== id) {
|
||||
throw new Error('Email already exists')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isActive: users.isActive,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
lastLoginAt: users.lastLoginAt,
|
||||
})
|
||||
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
export const deleteUser = async (id: string) => {
|
||||
// 检查用户是否存在
|
||||
const existingUser = await getUserById(id)
|
||||
if (!existingUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [deletedUser] = await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
})
|
||||
|
||||
return deletedUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户密码
|
||||
*/
|
||||
export const updateUserPassword = async (id: string, password: string) => {
|
||||
// 检查用户是否存在
|
||||
const existingUser = await getUserById(id)
|
||||
if (!existingUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 加密新密码
|
||||
const passwordHash = await Bun.password.hash(password)
|
||||
|
||||
// 更新密码
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
passwordHash,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
})
|
||||
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user