feat: basic api server

This commit is contained in:
Acbox
2026-01-10 20:17:02 +08:00
parent 28aa28e5bb
commit e60c0bb0d7
31 changed files with 1421 additions and 7 deletions
+16 -2
View File
@@ -4,10 +4,24 @@
"scripts": { "scripts": {
"dev": "bun run --env-file=../../.env --watch src/index.ts", "dev": "bun run --env-file=../../.env --watch src/index.ts",
"build": "bun build src/index.ts --outfile dist/index.js --target bun --minify", "build": "bun build src/index.ts --outfile dist/index.js --target bun --minify",
"start": "bun run dist/index.js" "start": "bun run dist/index.js",
"test": "vitest"
},
"exports": {
"./client": "./src/client.ts"
}, },
"dependencies": { "dependencies": {
"elysia": "latest" "@elysiajs/cors": "^1.4.1",
"@elysiajs/cron": "^1.4.1",
"@elysiajs/eden": "^1.4.6",
"@memohome/agent": "workspace:*",
"@memohome/db": "workspace:*",
"@memohome/memory": "workspace:*",
"@memohome/shared": "workspace:*",
"drizzle-orm": "^0.45.1",
"elysia": "latest",
"node-cron": "^4.2.1",
"zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "latest" "bun-types": "latest"
+10
View File
@@ -0,0 +1,10 @@
import { app } from './index'
import { treaty } from '@elysiajs/eden'
export type ApiClient = typeof app
export const createClient = (
baseUrl: string = process.env.API_BASE_URL ?? 'http://localhost:7002'
) => {
return treaty<ApiClient>(baseUrl)
}
+10 -1
View File
@@ -1,8 +1,17 @@
import { Elysia } from 'elysia' import { Elysia } from 'elysia'
import { corsMiddleware } from './middlewares'
import { agentModule, modelModule, settingsModule } from './modules'
import { memoryModule } from './modules/memory'
const port = process.env.API_SERVER_PORT || 7002 const port = process.env.API_SERVER_PORT || 7002
const app = new Elysia().get('/', () => 'Hello Elysia').listen(port) export const app = new Elysia()
.use(corsMiddleware)
.use(agentModule)
.use(memoryModule)
.use(modelModule)
.use(settingsModule)
.listen(port)
console.log( console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
+9
View File
@@ -0,0 +1,9 @@
import cors from '@elysiajs/cors'
export const corsMiddleware = cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
+1
View File
@@ -0,0 +1 @@
export * from './cors'
+5
View File
@@ -0,0 +1,5 @@
import Elysia from 'elysia'
export const agentModule = new Elysia({
prefix: '/agent',
})
+3
View File
@@ -0,0 +1,3 @@
export * from './agent'
export * from './model'
export * from './settings'
+40
View File
@@ -0,0 +1,40 @@
import Elysia from 'elysia'
import { messageModule } from './message'
import { AddMemoryModel, SearchMemoryModel } from './model'
import { addMemory, searchMemory } from './service'
import { MemoryUnit } from '@memohome/memory'
export const memoryModule = new Elysia({
prefix: '/memory',
})
.use(messageModule)
// Add memory
.post('/', async ({ body }) => {
try {
const result = await addMemory(body as unknown as MemoryUnit)
return {
success: true,
data: result,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add memory',
}
}
}, AddMemoryModel)
// Search memory
.get('/search', async ({ query }) => {
try {
const results = await searchMemory(query.query, query.userId)
return {
success: true,
data: results,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to search memory',
}
}
}, SearchMemoryModel)
@@ -0,0 +1,22 @@
import Elysia from 'elysia'
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,
}
}, GetMemoryMessageModel)
.get('/filter', async ({ query }) => {
const units = await getMemoryMessagesFilter(query)
return {
units,
success: true,
}
}, GetMemoryMessageFilterModel)
@@ -0,0 +1,17 @@
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(),
}),
}
export const GetMemoryMessageFilterModel = {
query: z.object({
from: z.date(),
to: z.date(),
userId: z.string(),
}),
}
@@ -0,0 +1,43 @@
import { db } from '@memohome/db'
import { history } from '@memohome/db/schema'
import { eq, desc, and, gte, lte, asc } from 'drizzle-orm'
export const getMemoryMessages = async (
query: {
limit: number
page: number
userId: string
}
) => {
const { limit, page, userId } = query
const results = await db
.select()
.from(history)
.where(eq(history.user, userId))
.orderBy(desc(history.timestamp))
.limit(limit)
.offset((page - 1) * limit)
return results
}
export const getMemoryMessagesFilter = async (
query: {
from: Date
to: Date
userId: string
}
) => {
const { from, to, userId } = query
const results = await db
.select()
.from(history)
.where(and(
gte(history.timestamp, from),
lte(history.timestamp, to),
eq(history.user, userId),
))
.orderBy(asc(history.timestamp))
return results
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
// MemoryUnit schema
const MemoryUnitSchema = z.object({
messages: z.array(z.object()),
timestamp: z.coerce.date(),
user: z.string(),
})
export const AddMemoryModel = {
body: MemoryUnitSchema,
}
export const SearchMemoryModel = {
query: z.object({
query: z.string().min(1, 'Search query is required'),
userId: z.string().min(1, 'User ID is required'),
}),
}
@@ -0,0 +1,35 @@
import { createMemory, MemoryUnit } from '@memohome/memory'
import { getEmbeddingModel, getSummaryModel } from '@/modules/model/service'
import { ChatModel, EmbeddingModel } from '@memohome/shared'
export const addMemory = async (memoryUnit: MemoryUnit) => {
const [embeddingModel, summaryModel] = await Promise.all([
getEmbeddingModel(memoryUnit.user),
getSummaryModel(memoryUnit.user),
])
if (!embeddingModel || !summaryModel) {
throw new Error('Embedding or summary model not found')
}
const { addMemory } = createMemory({
summaryModel: summaryModel.model as ChatModel,
embeddingModel: embeddingModel.model as EmbeddingModel,
})
await addMemory(memoryUnit)
return memoryUnit
}
export const searchMemory = async (query: string, userId: string) => {
const [embeddingModel, summaryModel] = await Promise.all([
getEmbeddingModel(userId),
getSummaryModel(userId),
])
if (!embeddingModel || !summaryModel) {
throw new Error('Embedding or summary model not found')
}
const { searchMemory } = createMemory({
summaryModel: summaryModel.model as ChatModel,
embeddingModel: embeddingModel.model as EmbeddingModel,
})
const results = await searchMemory(query, userId)
return results
}
+185
View File
@@ -0,0 +1,185 @@
import Elysia from 'elysia'
import {
CreateModelModel,
UpdateModelModel,
GetModelByIdModel,
DeleteModelModel,
GetDefaultModelModel,
} from './model'
import {
getModels,
getModelById,
createModel,
updateModel,
deleteModel,
getChatModel,
getSummaryModel,
getEmbeddingModel,
} from './service'
import { Model } from '@memohome/shared'
export const modelModule = new Elysia({
prefix: '/model',
})
// Get all models
.get('/', async () => {
try {
const models = await getModels()
return {
success: true,
data: models,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch models',
}
}
})
// Get model by ID
.get('/:id', async ({ params }) => {
try {
const { id } = params
const model = await getModelById(id)
if (!model) {
return {
success: false,
error: 'Model not found',
}
}
return {
success: true,
data: model,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch model',
}
}
}, GetModelByIdModel)
// Create new model
.post('/', async ({ body }) => {
try {
const newModel = await createModel(body as Model)
return {
success: true,
data: newModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create model',
}
}
}, CreateModelModel)
// Update model
.put('/:id', async ({ params, body }) => {
try {
const { id } = params
const updatedModel = await updateModel(id, body as Model)
if (!updatedModel) {
return {
success: false,
error: 'Model not found',
}
}
return {
success: true,
data: updatedModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update model',
}
}
}, UpdateModelModel)
// Delete model
.delete('/:id', async ({ params }) => {
try {
const { id } = params
const deletedModel = await deleteModel(id)
if (!deletedModel) {
return {
success: false,
error: 'Model not found',
}
}
return {
success: true,
data: deletedModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete model',
}
}
}, DeleteModelModel)
// Get default chat model
.get('/chat/default', async ({ query }) => {
try {
const { userId } = query
const chatModel = await getChatModel(userId)
if (!chatModel) {
return {
success: false,
error: 'Default chat model not found or not set',
}
}
return {
success: true,
data: chatModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch default chat model',
}
}
}, GetDefaultModelModel)
// Get default summary model
.get('/summary/default', async ({ query }) => {
try {
const { userId } = query
const summaryModel = await getSummaryModel(userId)
if (!summaryModel) {
return {
success: false,
error: 'Default summary model not found or not set',
}
}
return {
success: true,
data: summaryModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch default summary model',
}
}
}, GetDefaultModelModel)
// Get default embedding model
.get('/embedding/default', async ({ query }) => {
try {
const { userId } = query
const embeddingModel = await getEmbeddingModel(userId)
if (!embeddingModel) {
return {
success: false,
error: 'Default embedding model not found or not set',
}
}
return {
success: true,
data: embeddingModel,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch default embedding model',
}
}
}, GetDefaultModelModel)
+55
View File
@@ -0,0 +1,55 @@
import { z } from 'zod'
const BaseModelSchema = z.object({
modelId: z.string().min(1, 'Model ID is required'),
baseUrl: z.string(),
apiKey: z.string().min(1, 'API key is required'),
clientType: z.string(),
name: z.string().optional(),
})
// Chat model schema (type is optional and defaults to 'chat')
const ChatModelSchema = BaseModelSchema.extend({
type: z.enum(['chat']).optional().default('chat'),
})
// Embedding model schema (type must be 'embedding' and dimensions is required)
const EmbeddingModelSchema = BaseModelSchema.extend({
type: z.literal('embedding'),
dimensions: z.number().int().positive('Dimensions must be a positive integer'),
})
// Union of both model types
const ModelSchema = z.union([ChatModelSchema, EmbeddingModelSchema])
// Export the inferred type from the schema
export type ModelInput = z.infer<typeof ModelSchema>
export const CreateModelModel = {
body: ModelSchema,
}
export const UpdateModelModel = {
params: z.object({
id: z.string(),
}),
body: ModelSchema,
}
export const GetModelByIdModel = {
params: z.object({
id: z.string(),
}),
}
export const DeleteModelModel = {
params: z.object({
id: z.string(),
}),
}
export const GetDefaultModelModel = {
query: z.object({
userId: z.string().min(1, 'User ID is required'),
}),
}
+64
View File
@@ -0,0 +1,64 @@
import { db } from '@memohome/db'
import { model } from '@memohome/db/schema'
import { Model } from '@memohome/shared'
import { eq } from 'drizzle-orm'
import { getSettings } from '@/modules/settings/service'
export const getModels = async () => {
const models = await db.select().from(model)
return models
}
export const getModelById = async (id: string) => {
const [result] = await db.select().from(model).where(eq(model.id, id))
return result
}
export const createModel = async (data: Model) => {
const [newModel] = await db
.insert(model)
.values({ model: data })
.returning()
return newModel
}
export const updateModel = async (id: string, data: Model) => {
const [updatedModel] = await db
.update(model)
.set({ model: data })
.where(eq(model.id, id))
.returning()
return updatedModel
}
export const deleteModel = async (id: string) => {
const [deletedModel] = await db
.delete(model)
.where(eq(model.id, id))
.returning()
return deletedModel
}
export const getChatModel = async (userId: string) => {
const userSettings = await getSettings(userId)
if (!userSettings?.defaultChatModel) {
return null
}
return await getModelById(userSettings.defaultChatModel)
}
export const getSummaryModel = async (userId: string) => {
const userSettings = await getSettings(userId)
if (!userSettings?.defaultSummaryModel) {
return null
}
return await getModelById(userSettings.defaultSummaryModel)
}
export const getEmbeddingModel = async (userId: string) => {
const userSettings = await getSettings(userId)
if (!userSettings?.defaultEmbeddingModel) {
return null
}
return await getModelById(userSettings.defaultEmbeddingModel)
}
@@ -0,0 +1,91 @@
import Elysia from 'elysia'
import {
GetSettingsModel,
CreateSettingsModel,
UpdateSettingsModel,
} from './model'
import {
getSettings,
createSettings,
updateSettings,
upsertSettings,
} from './service'
export const settingsModule = new Elysia({
prefix: '/settings',
})
// Get user settings
.get('/:userId', async ({ params }) => {
try {
const { userId } = params
const userSettings = await getSettings(userId)
if (!userSettings) {
return {
success: false,
error: 'Settings not found',
}
}
return {
success: true,
data: userSettings,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch settings',
}
}
}, GetSettingsModel)
// Create new settings
.post('/', async ({ body }) => {
try {
const newSettings = await createSettings(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,
}
} catch (error) {
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)
@@ -0,0 +1,32 @@
import { z } from 'zod'
const SettingsSchema = z.object({
userId: z.string().min(1, 'User ID is required'),
defaultChatModel: z.string().uuid().nullable().optional(),
defaultEmbeddingModel: z.string().uuid().nullable().optional(),
defaultSummaryModel: z.string().uuid().nullable().optional(),
})
export type SettingsInput = z.infer<typeof SettingsSchema>
export const GetSettingsModel = {
params: z.object({
userId: z.string(),
}),
}
export const CreateSettingsModel = {
body: SettingsSchema,
}
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(),
}),
}
@@ -0,0 +1,63 @@
import { db } from '@memohome/db'
import { settings } from '@memohome/db/schema'
import { eq } from 'drizzle-orm'
import type { SettingsInput } from './model'
export const getSettings = async (userId: string) => {
const [result] = await db
.select()
.from(settings)
.where(eq(settings.userId, userId))
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 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,
defaultChatModel: data.defaultChatModel || null,
defaultEmbeddingModel: data.defaultEmbeddingModel || null,
defaultSummaryModel: data.defaultSummaryModel || null,
})
.onConflictDoUpdate({
target: settings.userId,
set: {
defaultChatModel: data.defaultChatModel,
defaultEmbeddingModel: data.defaultEmbeddingModel,
defaultSummaryModel: data.defaultSummaryModel,
},
})
.returning()
return result
}
+39
View File
@@ -0,0 +1,39 @@
# API Tests
这个目录包含了使用 Elysia Eden Client 编写的 API 测试。
## 测试文件
- `setup.ts` - 测试设置文件,配置测试服务器和 eden client
- `memory.test.ts` - Memory API 测试
- `memory-message.test.ts` - Memory Message API 测试
- `model.test.ts` - Model API 测试
- `settings.test.ts` - Settings API 测试
## 运行测试
从项目根目录运行:
```bash
pnpm test
```
或者只运行 API 包的测试:
```bash
cd packages/api
pnpm test
```
## 测试说明
测试使用 vitest 作为测试框架,并使用 Elysia Eden Client (treaty) 来测试 API 端点。
测试服务器会在测试开始前启动(端口 7003),测试结束后自动关闭。
## 注意事项
- 确保数据库已配置并运行(某些测试可能需要数据库连接)
- 测试使用独立的测试端口(7003)以避免与开发服务器冲突
- 某些测试可能需要先创建数据才能测试查询功能
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { getTestClient } from './setup'
describe('Memory Message API', () => {
const client = getTestClient()
describe('GET /memory/message', () => {
it('should get memory messages successfully', async () => {
const response = await client.memory.message.get({
query: {
userId: 'test-user-123',
limit: 10,
page: 1,
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.units).toBeDefined()
})
it('should use default limit and page when not provided', async () => {
const response = await client.memory.message.get({
query: {
userId: 'test-user-123',
limit: 10,
page: 1,
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
})
it('should return error for missing userId', async () => {
const response = await client.memory.message.get({
// @ts-expect-error - Testing invalid input
query: {
limit: 10,
page: 1,
// missing userId
},
})
expect([400, 422]).toContain(response.status)
})
})
describe('GET /memory/message/filter', () => {
it('should filter memory messages successfully', async () => {
const response = await client.memory.message.filter.get({
query: {
userId: 'test-user-123',
from: new Date('2024-01-01') as unknown as string,
to: new Date('2024-12-31') as unknown as string,
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.units).toBeDefined()
})
it('should return error for missing required fields', async () => {
const response = await client.memory.message.filter.get({
// @ts-expect-error - Testing invalid input
query: {
userId: 'test-user-123',
// missing from and to
},
})
expect([400, 422]).toContain(response.status)
})
})
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import { getTestClient } from './setup'
describe('Memory API', () => {
const client = getTestClient()
describe('POST /memory', () => {
it('should add memory successfully', async () => {
const memoryData = {
messages: [
{ role: 'user', content: 'Hello, this is a test message' },
{ role: 'assistant', content: 'Hello! How can I help you?' },
],
timestamp: new Date(),
user: 'test-user-123',
}
const response = await client.memory.post(memoryData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
console.log(response.data?.error)
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
})
it('should return error for invalid memory data', async () => {
const invalidData = {
messages: [],
// missing timestamp and user
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.memory.post(invalidData as any)
// Elysia 会返回 400 或 422 对于验证错误
expect([400, 422]).toContain(response.status)
})
})
describe('GET /memory/search', () => {
it('should search memory successfully', async () => {
const response = await client.memory.search.get({
query: {
query: 'test search',
userId: 'test-user-123',
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(Array.isArray(response.data?.data)).toBe(true)
})
it('should return error for missing query', async () => {
const response = await client.memory.search.get({
// @ts-expect-error - Testing invalid input
query: {
userId: 'test-user-123',
// missing query
},
})
expect([400, 422]).toContain(response.status)
})
it('should return error for missing userId', async () => {
const response = await client.memory.search.get({
// @ts-expect-error - Testing invalid input
query: {
query: 'test search',
// missing userId
},
})
expect([400, 422]).toContain(response.status)
})
})
})
+238
View File
@@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { getTestClient } from './setup'
describe('Model API', () => {
const client = getTestClient()
let createdModelId: string | null = null
describe('GET /model', () => {
it('should get all models successfully', async () => {
const response = await client.model.get()
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(Array.isArray(response.data?.data)).toBe(true)
})
})
describe('POST /model', () => {
it('should create a chat model successfully', async () => {
const modelData = {
modelId: 'test-chat-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
name: 'Test Chat Model',
type: 'chat' as const,
}
const response = await client.model.post(modelData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
if (response.data?.data?.id) {
createdModelId = response.data.data.id
}
})
it('should create an embedding model successfully', async () => {
const modelData = {
modelId: 'test-embedding-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
name: 'Test Embedding Model',
type: 'embedding' as const,
dimensions: 1536,
}
const response = await client.model.post(modelData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
})
it('should return error for invalid model data', async () => {
const invalidData = {
// missing required fields
name: 'Invalid Model',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.model.post(invalidData as any)
expect([400, 422]).toContain(response.status)
})
it('should return error for embedding model without dimensions', async () => {
const invalidData = {
modelId: 'test-embedding-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
type: 'embedding' as const,
// missing dimensions
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.model.post(invalidData as any)
expect([400, 422]).toContain(response.status)
})
})
describe('GET /model/:id', () => {
it('should get model by id successfully', async () => {
if (!createdModelId) {
// 先创建一个模型
const createResponse = await client.model.post({
modelId: 'test-get-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
type: 'chat' as const,
})
if (createResponse.data?.data?.id) {
createdModelId = createResponse.data.data.id
}
}
if (createdModelId) {
const response = await client.model({
id: createdModelId,
}).get()
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
}
})
it('should return error for non-existent model', async () => {
const response = await client.model({
id: 'non-existent-id',
}).get()
expect(response.status).toBe(200) // API 返回 200 但 success: false
expect(response.data?.success).toBe(false)
expect(response.data?.error).toBeDefined()
})
})
describe('PUT /model/:id', () => {
it('should update model successfully', async () => {
if (!createdModelId) {
const createResponse = await client.model.post({
modelId: 'test-update-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
type: 'chat' as const,
})
if (createResponse.data?.data?.id) {
createdModelId = createResponse.data.data.id
}
}
if (createdModelId) {
const updateData = {
modelId: 'test-updated-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'updated-api-key',
clientType: 'openai',
name: 'Updated Model',
type: 'chat' as const,
}
const response = await client.model[createdModelId].put(updateData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
}
})
})
describe('DELETE /model/:id', () => {
it('should delete model successfully', async () => {
// 先创建一个模型用于删除
const createResponse = await client.model.post({
modelId: 'test-delete-model',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
clientType: 'openai',
type: 'chat' as const,
})
const modelId = createResponse.data?.data?.id
if (modelId) {
const response = await client.model[modelId].delete()
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
}
})
})
describe('GET /model/chat/default', () => {
it('should get default chat model successfully', async () => {
const response = await client.model.chat.default.get({
query: {
userId: 'test-user-123',
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
// 可能返回 success: false 如果没有设置默认模型
expect(response.data?.success !== undefined).toBe(true)
})
it('should return error for missing userId', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.model.chat.default.get({ query: {} } as any)
expect([400, 422]).toContain(response.status)
})
})
describe('GET /model/summary/default', () => {
it('should get default summary model successfully', async () => {
const response = await client.model.summary.default.get({
query: {
userId: 'test-user-123',
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success !== undefined).toBe(true)
})
})
describe('GET /model/embedding/default', () => {
it('should get default embedding model successfully', async () => {
const response = await client.model.embedding.default.get({
query: {
userId: 'test-user-123',
},
})
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success !== undefined).toBe(true)
})
})
})
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { getTestClient } from './setup'
describe('Settings API', () => {
const client = getTestClient()
const testUserId = 'test-user-settings-123'
describe('GET /settings/:userId', () => {
it('should get settings successfully', async () => {
const response = await client.settings({
userId: testUserId,
}).get()
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
// 可能返回 success: false 如果设置不存在
expect(response.data?.success !== undefined).toBe(true)
})
})
describe('POST /settings', () => {
it('should create settings successfully', async () => {
const settingsData = {
userId: testUserId,
defaultChatModel: null,
defaultEmbeddingModel: null,
defaultSummaryModel: null,
}
const response = await client.settings.post(settingsData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
})
it('should create settings with model IDs', async () => {
const settingsData = {
userId: `${testUserId}-with-models`,
defaultChatModel: '00000000-0000-0000-0000-000000000001',
defaultEmbeddingModel: '00000000-0000-0000-0000-000000000002',
defaultSummaryModel: '00000000-0000-0000-0000-000000000003',
}
const response = await client.settings.post(settingsData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
})
it('should return error for invalid UUID format', async () => {
const invalidData = {
userId: testUserId,
defaultChatModel: 'invalid-uuid',
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.settings.post(invalidData as any)
expect([400, 422]).toContain(response.status)
})
it('should return error for missing userId', async () => {
const invalidData = {
defaultChatModel: null,
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await client.settings.post(invalidData as any)
expect([400, 422]).toContain(response.status)
})
})
describe('PUT /settings/:userId', () => {
it('should update settings successfully', async () => {
// 先创建设置
await client.settings.post({
userId: `${testUserId}-update`,
defaultChatModel: null,
})
const updateData = {
defaultChatModel: '00000000-0000-0000-0000-000000000001',
defaultEmbeddingModel: '00000000-0000-0000-0000-000000000002',
defaultSummaryModel: null,
}
const response = await client.settings({
userId: `${testUserId}-update`,
}).put(updateData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
})
it('should return error for non-existent settings', async () => {
const updateData = {
defaultChatModel: '00000000-0000-0000-0000-000000000001',
}
const response = await client.settings({
userId: 'non-existent-user',
}).put(updateData)
expect(response.status).toBe(200) // API 返回 200 但 success: false
expect(response.data?.success).toBe(false)
expect(response.data?.error).toBeDefined()
})
})
describe('PATCH /settings', () => {
it('should upsert settings successfully', async () => {
const settingsData = {
userId: `${testUserId}-upsert`,
defaultChatModel: '00000000-0000-0000-0000-000000000001',
defaultEmbeddingModel: null,
defaultSummaryModel: null,
}
const response = await client.settings.patch(settingsData)
expect(response.status).toBe(200)
expect(response.data).toBeDefined()
expect(response.data?.success).toBe(true)
expect(response.data?.data).toBeDefined()
})
it('should update existing settings on upsert', async () => {
const userId = `${testUserId}-upsert-update`
// 先创建
await client.settings.post({
userId,
defaultChatModel: null,
})
// 然后 upsert
const upsertData = {
userId,
defaultChatModel: '00000000-0000-0000-0000-000000000001',
}
const response = await client.settings.patch(upsertData)
expect(response.status).toBe(200)
expect(response.data?.success).toBe(true)
})
})
})
+7
View File
@@ -0,0 +1,7 @@
import { createClient } from '../src/client'
export const getTestClient = () => {
return createClient()
}
+7 -3
View File
@@ -27,7 +27,7 @@
/* Modules */ /* Modules */
"module": "ES2022", /* Specify what module code is generated. */ "module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */ // "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ "moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
@@ -98,6 +98,10 @@
/* Completeness */ /* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */ "skipLibCheck": true, /* Skip type checking all .d.ts files. */
} "paths": {
"@/*": ["./src/*"]
},
},
"include": ["src/**/*", "src/**/**/*"]
} }
+1
View File
@@ -13,6 +13,7 @@
"studio": "drizzle-kit studio" "studio": "drizzle-kit studio"
}, },
"dependencies": { "dependencies": {
"@memohome/shared": "workspace:*",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1", "drizzle-orm": "^0.45.1",
"pg": "^8.16.3" "pg": "^8.16.3"
+7
View File
@@ -0,0 +1,7 @@
import { Model } from '@memohome/shared'
import { jsonb, pgTable, uuid } from 'drizzle-orm/pg-core'
export const model = pgTable('model', {
id: uuid('id').primaryKey().defaultRandom(),
model: jsonb('model').notNull().$type<Model>(),
})
+3 -1
View File
@@ -1 +1,3 @@
export * from './history' export * from './history'
export * from './model'
export * from './settings'
+9
View File
@@ -0,0 +1,9 @@
import { pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { model } from './model'
export const settings = pgTable('settings', {
userId: text('user_id').primaryKey(),
defaultChatModel: uuid('default_chat_model').references(() => model.id),
defaultEmbeddingModel: uuid('default_embedding_model').references(() => model.id),
defaultSummaryModel: uuid('default_summary_model').references(() => model.id),
})
+73
View File
@@ -90,9 +90,39 @@ importers:
packages/api: packages/api:
dependencies: dependencies:
'@elysiajs/cors':
specifier: ^1.4.1
version: 1.4.1(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))
'@elysiajs/cron':
specifier: ^1.4.1
version: 1.4.1(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))
'@elysiajs/eden':
specifier: ^1.4.6
version: 1.4.6(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))
'@memohome/agent':
specifier: workspace:*
version: link:../agent
'@memohome/db':
specifier: workspace:*
version: link:../db
'@memohome/memory':
specifier: workspace:*
version: link:../memory
'@memohome/shared':
specifier: workspace:*
version: link:../shared
drizzle-orm:
specifier: ^0.45.1
version: 0.45.1(@cloudflare/workers-types@4.20260109.0)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(bun-types@1.3.5)(pg@8.16.3)(sqlite3@5.1.7)
elysia: elysia:
specifier: latest specifier: latest
version: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3) version: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)
node-cron:
specifier: ^4.2.1
version: 4.2.1
zod:
specifier: ^4.3.5
version: 4.3.5
devDependencies: devDependencies:
bun-types: bun-types:
specifier: latest specifier: latest
@@ -100,6 +130,9 @@ importers:
packages/db: packages/db:
dependencies: dependencies:
'@memohome/shared':
specifier: workspace:*
version: link:../shared
dotenv: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
@@ -524,6 +557,21 @@ packages:
'@drizzle-team/brocli@0.10.2': '@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@elysiajs/cors@1.4.1':
resolution: {integrity: sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ==}
peerDependencies:
elysia: '>= 1.4.0'
'@elysiajs/cron@1.4.1':
resolution: {integrity: sha512-Y+jqXtMJ+m17QzNWlWc09ugd1Fn1Wh7lqE+y9qSW8eiQZEqsRADXWmbLuXRSRaSC5dkfOyRSiwNCp6n8L/yuqA==}
peerDependencies:
elysia: '>= 1.4.0'
'@elysiajs/eden@1.4.6':
resolution: {integrity: sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q==}
peerDependencies:
elysia: '>=1.4.19'
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.is'
@@ -2129,6 +2177,10 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
croner@6.0.7:
resolution: {integrity: sha512-k3Xx3Rcclfr60Yx4TmvsF3Yscuiql8LSvYLaphTsaq5Hk8La4Z/udmUANMOTKpgGGroI2F6/XOr9cU9OFkYluQ==}
engines: {node: '>=6.0'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3253,6 +3305,10 @@ packages:
node-addon-api@7.1.1: node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-cron@4.2.1:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-domexception@1.0.0: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -4592,6 +4648,19 @@ snapshots:
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
'@elysiajs/cors@1.4.1(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))':
dependencies:
elysia: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)
'@elysiajs/cron@1.4.1(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))':
dependencies:
croner: 6.0.7
elysia: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)
'@elysiajs/eden@1.4.6(elysia@1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3))':
dependencies:
elysia: 1.4.21(@sinclair/typebox@0.34.47)(exact-mirror@0.2.6(@sinclair/typebox@0.34.47))(file-type@21.3.0)(openapi-types@12.1.3)(typescript@5.9.3)
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
@@ -6139,6 +6208,8 @@ snapshots:
dependencies: dependencies:
is-what: 5.5.0 is-what: 5.5.0
croner@6.0.7: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -7253,6 +7324,8 @@ snapshots:
node-addon-api@7.1.1: {} node-addon-api@7.1.1: {}
node-cron@4.2.1: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-fetch@2.7.0(encoding@0.1.13): node-fetch@2.7.0(encoding@0.1.13):