feat: full api server

This commit is contained in:
Acbox
2026-01-10 21:55:39 +08:00
parent e60c0bb0d7
commit 661d742750
40 changed files with 3587 additions and 157 deletions
+46 -3
View File
@@ -1,9 +1,52 @@
# Server
# ==================================
# Database Configuration
# ==================================
# PostgreSQL database connection URL
# Format: postgresql://username:password@host:port/database
# Example: postgresql://postgres:password@localhost:5432/memohome
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
# ==================================
# API Server Configuration
# ==================================
API_SERVER_PORT=7002
WEB_PORT=7003
API_BASE_URL=http://localhost:7002
WEB_URL=http://localhost:7003
# Database (PostgreSQL)
DATABASE_URL=
# ==================================
# Authentication Configuration
# ==================================
# Root user credentials (super admin)
# This user is not stored in the database and has full admin privileges
# Use a strong username and password in production
ROOT_USER=admin
ROOT_USER_PASSWORD=change_this_secure_password
# JWT secret key for signing tokens
# IMPORTANT: Use a long, random string in production (at least 32 characters)
# You can generate one with: openssl rand -base64 32
JWT_SECRET=your-jwt-secret-key-change-in-production-use-long-random-string
# JWT token expiration time
# Format: number + unit (s=seconds, m=minutes, h=hours, d=days)
# Examples: 30m, 2h, 7d, 30d
# Default: 7d (7 days)
JWT_EXPIRES_IN=7d
# ==================================
# Optional: Development Configuration
# ==================================
# Node environment
# Options: development, production, test
NODE_ENV=development
# Enable debug logging (optional)
# DEBUG=true
+1
View File
@@ -90,3 +90,4 @@ Thumbs.db
*.temp
.cache/
.pnpm-store
-3
View File
@@ -12,9 +12,6 @@
"web:dev": "pnpm --filter @memohome/web dev",
"web:build": "pnpm --filter @memohome/web build",
"web:start": "pnpm --filter @memohome/web start",
"device-api:dev": "pnpm --filter @memohome/device-api dev",
"device-api:build": "pnpm --filter @memohome/device-api build",
"device-api:start": "pnpm --filter @memohome/device-api start",
"db:push": "pnpm --filter @memohome/db push",
"db:migrate": "pnpm --filter @memohome/db migrate",
"db:generate": "pnpm --filter @memohome/db generate",
+4
View File
@@ -2,6 +2,10 @@
"name": "@memohome/agent",
"version": "1.0.0",
"description": "Agent package for the phonetutor monorepo",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest",
"start": "bun run client/index.ts"
+383
View File
@@ -0,0 +1,383 @@
# Agent API 文档
## 概述
Agent API 提供了一个智能对话代理接口,支持流式响应、记忆管理和工具调用。
## 端点
### POST `/agent/stream` 🔒 需要认证
与 AI Agent 进行流式对话。
#### 权限要求
- 需要有效的 Bearer token
- 自动使用当前登录用户的身份
#### 请求
**Headers:**
```
Authorization: Bearer <your_jwt_token>
Content-Type: application/json
```
**Body:**
```json
{
"message": "你好,请介绍一下你自己",
"maxContextLoadTime": 60,
"language": "Chinese"
}
```
**参数说明:**
| 字段 | 类型 | 必需 | 默认值 | 说明 |
|------|------|------|--------|------|
| message | string | 是 | - | 用户消息内容 |
| maxContextLoadTime | number | 否 | 从设置读取或60 | 加载上下文的时间范围(分钟,1-1440) |
| language | string | 否 | 从设置读取或"Same as user input" | Agent 回复的首选语言 |
**注意**: `maxContextLoadTime``language` 如果在请求中未指定,将从用户设置(Settings)中读取。如果设置中也没有,则使用默认值。
#### 响应
返回 Server-Sent Events (SSE) 流式响应。
**Content-Type:** `text/event-stream`
**事件格式:**
每个事件以 `data: ` 开头,后跟 JSON 格式的数据:
```
data: {"type":"text-delta","text":"你"}
data: {"type":"text-delta","text":"好"}
data: {"type":"tool-call","toolName":"search_memory","args":{...}}
data: [DONE]
```
**事件类型:**
1. **text-delta** - 文本增量
```json
{
"type": "text-delta",
"text": "文本片段"
}
```
2. **tool-call** - 工具调用
```json
{
"type": "tool-call",
"toolName": "search_memory",
"args": {
"query": "搜索内容"
}
}
```
3. **[DONE]** - 流结束标记
4. **error** - 错误事件
```json
{
"type": "error",
"error": "错误消息"
}
```
#### 错误响应
**400 Bad Request** - 模型配置未找到
```json
{
"success": false,
"error": "Model configuration not found. Please configure your models in settings."
}
```
**401 Unauthorized** - 未认证
```json
{
"success": false,
"error": "No bearer token provided"
}
```
**500 Internal Server Error** - 服务器错误
```json
{
"success": false,
"error": "Failed to process request"
}
```
---
## 使用示例
### JavaScript/TypeScript (EventSource)
```typescript
async function streamAgentChat(message: string, token: string) {
const response = await fetch('http://localhost:7002/agent/stream', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
maxContextLoadTime: 60,
language: 'Chinese',
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error)
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No reader available')
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
console.log('\n✅ Stream completed')
return
}
try {
const event = JSON.parse(data)
if (event.type === 'text-delta' && event.text) {
process.stdout.write(event.text)
} else if (event.type === 'tool-call') {
console.log(`\n[Tool: ${event.toolName}]`)
} else if (event.type === 'error') {
console.error('\n❌ Error:', event.error)
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
}
// 使用示例
const token = 'your_jwt_token_here'
await streamAgentChat('你好,请介绍一下你自己', token)
```
### curl 示例
```bash
# 1. 登录获取 token
TOKEN=$(curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}' \
| jq -r '.data.token')
# 2. 流式对话
curl -N -X POST http://localhost:7002/agent/stream \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": "你好,请介绍一下你自己",
"maxContextLoadTime": 60,
"language": "Chinese"
}'
```
### Python 示例
```python
import requests
import json
def stream_agent_chat(message: str, token: str):
url = 'http://localhost:7002/agent/stream'
headers = {
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
}
data = {
'message': message,
'maxContextLoadTime': 60,
'language': 'Chinese',
}
with requests.post(url, headers=headers, json=data, stream=True) as response:
response.raise_for_status()
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data = line_str[6:]
if data == '[DONE]':
print('\n✅ Stream completed')
break
try:
event = json.loads(data)
if event.get('type') == 'text-delta' and event.get('text'):
print(event['text'], end='', flush=True)
elif event.get('type') == 'tool-call':
print(f"\n[Tool: {event['toolName']}]")
elif event.get('type') == 'error':
print(f"\n❌ Error: {event['error']}")
except json.JSONDecodeError:
pass
# 使用示例
token = 'your_jwt_token_here'
stream_agent_chat('你好,请介绍一下你自己', token)
```
---
## 工作原理
### 1. 认证验证
- 验证 JWT token
- 提取用户信息
### 2. 模型配置获取
从用户设置中获取:
- Chat Model(对话模型)
- Embedding Model(嵌入模型)
- Summary Model(摘要模型)
### 3. Agent 创建
- 创建 Memory 实例(用于记忆管理)
- 创建 Agent 实例,配置回调函数:
- `onReadMemory`: 从数据库加载历史对话
- `onSearchMemory`: 搜索相关记忆
- `onFinish`: 保存对话到记忆
### 4. 流式响应
- Agent 处理用户消息
- 实时流式返回生成的文本
- 报告工具调用事件
- 完成后自动保存到记忆
### 5. 记忆管理
- 自动加载近期对话历史作为上下文
- 使用向量搜索查找相关记忆
- 对话结束后自动保存
---
## 特性
### ✅ 流式响应
- 实时返回生成的文本
- 无需等待完整响应
- 更好的用户体验
### ✅ 记忆管理
- 自动保存对话历史
- 智能加载相关上下文
- 向量搜索相关记忆
### ✅ 工具调用
- 支持搜索记忆工具
- 可扩展其他工具
- 实时报告工具使用
### ✅ 个性化配置
- 每个用户使用自己的模型配置
- 可自定义语言偏好
- 可配置上下文加载时间
---
## 前置条件
### 1. 配置模型和 Agent 设置
使用 Settings API 配置默认模型和 Agent 参数:
```bash
curl -X PUT http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaultChatModel": "chat-model-uuid",
"defaultEmbeddingModel": "embedding-model-uuid",
"defaultSummaryModel": "summary-model-uuid",
"maxContextLoadTime": 60,
"language": "Chinese"
}'
```
详见 [Settings API 文档](./SETTINGS_API.md)
### 2. 创建模型配置
使用 Model API 创建模型:
```bash
curl -X POST http://localhost:7002/model \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"modelId": "gpt-4",
"baseUrl": "https://api.openai.com/v1",
"apiKey": "your-api-key",
"clientType": "openai",
"name": "GPT-4"
}'
```
---
## 限制和注意事项
1. **模型配置必需**: 用户必须先配置好模型才能使用 Agent
2. **流式连接**: 需要支持 Server-Sent Events 的客户端
3. **超时处理**: 长时间无响应可能导致连接超时
4. **并发限制**: 建议每个用户同时只维护一个对话流
---
## 相关文档
- [认证系统](./AUTH_README.md)
- [用户管理](./USER_MANAGEMENT.md)
- [Settings API](./API_CHANGES.md)
- [Model API](./README.md)
- [Agent 包文档](../agent/README.md)
+251
View File
@@ -0,0 +1,251 @@
# API 变更说明
## 🔒 Settings 和 Memory 模块现已使用认证
### 概述
Settings 和 Memory 模块现在使用 JWT 认证中间件,自动从 token 中获取当前用户信息,**不再需要手动传入 userId**。
---
## Settings 模块变更
### ❌ 旧 API(已废弃)
```bash
# 获取用户设置
GET /settings/:userId
# 创建用户设置
POST /settings
{
"userId": "user123",
"defaultChatModel": "uuid-here"
}
# 更新用户设置
PUT /settings/:userId
{
"defaultChatModel": "uuid-here"
}
```
### ✅ 新 API(需要认证)
```bash
# 获取当前用户的设置
GET /settings
Authorization: Bearer <token>
# 更新或创建当前用户的设置
PUT /settings
Authorization: Bearer <token>
{
"defaultChatModel": "uuid-here",
"defaultEmbeddingModel": "uuid-here",
"defaultSummaryModel": "uuid-here",
"maxContextLoadTime": 60,
"language": "Chinese"
}
```
### 使用示例
```bash
# 1. 登录获取 token
TOKEN=$(curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"pass"}' \
| jq -r '.data.token')
# 2. 获取我的设置
curl http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN"
# 3. 更新我的设置
curl -X PUT http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaultChatModel": "123e4567-e89b-12d3-a456-426614174000",
"maxContextLoadTime": 60,
"language": "Chinese"
}'
```
---
## Memory 模块变更
### ❌ 旧 API(已废弃)
```bash
# 添加记忆
POST /memory
{
"messages": [...],
"timestamp": "2024-01-10T10:00:00Z",
"user": "user123"
}
# 搜索记忆
GET /memory/search?query=hello&userId=user123
# 获取消息历史
GET /memory/message?page=1&limit=10&userId=user123
# 按日期过滤消息
GET /memory/message/filter?from=2024-01-01&to=2024-01-31&userId=user123
```
### ✅ 新 API(需要认证)
```bash
# 添加记忆(自动使用当前用户)
POST /memory
Authorization: Bearer <token>
{
"messages": [...],
"timestamp": "2024-01-10T10:00:00Z"
}
# 搜索当前用户的记忆
GET /memory/search?query=hello
Authorization: Bearer <token>
# 获取当前用户的消息历史
GET /memory/message?page=1&limit=10
Authorization: Bearer <token>
# 按日期过滤当前用户的消息
GET /memory/message/filter?from=2024-01-01&to=2024-01-31
Authorization: Bearer <token>
```
### 使用示例
```bash
# 1. 登录获取 token
TOKEN=$(curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"pass"}' \
| jq -r '.data.token')
# 2. 添加记忆
curl -X POST http://localhost:7002/memory \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"}
],
"timestamp": "'$(date -u +"%Y-%m-%dT%H:%M:%SZ")'"
}'
# 3. 搜索记忆
curl "http://localhost:7002/memory/search?query=hello" \
-H "Authorization: Bearer $TOKEN"
# 4. 获取消息历史(分页)
curl "http://localhost:7002/memory/message?page=1&limit=10" \
-H "Authorization: Bearer $TOKEN"
# 5. 按日期范围过滤
curl "http://localhost:7002/memory/message/filter?from=2024-01-01&to=2024-01-31" \
-H "Authorization: Bearer $TOKEN"
```
---
## 安全优势
### ✅ 更安全
- 用户只能访问自己的数据
- 无法通过修改 userId 参数访问其他用户的数据
- 所有操作都需要有效的认证 token
### ✅ 更简洁
- API 调用更简单,无需手动传递 userId
- 减少了参数验证和错误处理
- 代码更清晰易维护
### ✅ 一致性
- 与其他需要认证的模块保持一致
- 统一的认证流程和错误处理
- 符合 RESTful 最佳实践
---
## 迁移指南
### 1. 更新客户端代码
#### 之前:
```javascript
// 需要手动传入 userId
const settings = await fetch(`/settings/${userId}`)
const memories = await fetch(`/memory/search?query=hello&userId=${userId}`)
```
#### 现在:
```javascript
// 自动使用 token 中的用户信息
const settings = await fetch('/settings', {
headers: { 'Authorization': `Bearer ${token}` }
})
const memories = await fetch('/memory/search?query=hello', {
headers: { 'Authorization': `Bearer ${token}` }
})
```
### 2. 错误处理
新增错误响应:
```json
// 401 Unauthorized - 未提供 token 或 token 无效
{
"success": false,
"error": "No bearer token provided"
}
// 401 Unauthorized - Token 过期
{
"success": false,
"error": "Invalid or expired token"
}
```
---
## API 端点总结
### Settings 模块 `/settings` 🔒
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| GET | `/` | 获取当前用户设置 | ✅ 必需 |
| PUT | `/` | 更新当前用户设置 | ✅ 必需 |
### Memory 模块 `/memory` 🔒
| 方法 | 路径 | 说明 | 认证 |
|------|------|------|------|
| POST | `/` | 添加记忆 | ✅ 必需 |
| GET | `/search` | 搜索记忆 | ✅ 必需 |
| GET | `/message` | 获取消息列表(分页) | ✅ 必需 |
| GET | `/message/filter` | 按日期范围过滤消息 | ✅ 必需 |
---
## 相关文档
- [认证系统文档](./AUTH_README.md)
- [用户管理文档](./USER_MANAGEMENT.md)
- [项目设置指南](../../SETUP.md)
+277
View File
@@ -0,0 +1,277 @@
# 认证系统使用文档
## 概述
本项目使用 JWT (JSON Web Token) 进行用户认证,集成了 Elysia 官方插件:
- [@elysiajs/jwt](https://elysiajs.com/plugins/jwt.html) - JWT token 生成和验证
- [@elysiajs/bearer](https://elysiajs.com/plugins/bearer.html) - Bearer token 提取
## 环境变量配置
在项目根目录的 `.env` 文件中配置以下变量:
```bash
# Root 超级用户配置
ROOT_USER=admin
ROOT_USER_PASSWORD=your_secure_password
# JWT 配置
JWT_SECRET=your-jwt-secret-key-change-in-production
JWT_EXPIRES_IN=7d # Token 有效期,可选
# API 服务器端口
API_SERVER_PORT=7002
```
## API 端点
### 1. 登录 POST `/auth/login`
用户登录并获取 JWT token。
**请求体:**
```json
{
"username": "admin",
"password": "your_password"
}
```
**成功响应:**
```json
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "root",
"username": "admin",
"role": "admin",
"displayName": "Root User",
"email": null
}
}
}
```
**失败响应:**
```json
{
"success": false,
"error": "Invalid username or password"
}
```
### 2. 验证 Token GET `/auth/verify`
验证 JWT token 是否有效。
**请求头:**
```
Authorization: Bearer <your_jwt_token>
```
**成功响应:**
```json
{
"success": true,
"data": {
"userId": "root",
"username": "admin",
"role": "admin"
}
}
```
**失败响应:**
```json
{
"success": false,
"error": "Invalid or expired token"
}
```
### 3. 获取当前用户信息 GET `/auth/me`
获取当前登录用户的信息。
**请求头:**
```
Authorization: Bearer <your_jwt_token>
```
**成功响应:**
```json
{
"success": true,
"data": {
"userId": "root",
"username": "admin",
"role": "admin"
}
}
```
## 使用示例
### JavaScript/TypeScript 客户端
```typescript
// 1. 登录
const loginResponse = await fetch('http://localhost:7002/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: 'admin',
password: 'your_password',
}),
})
const loginData = await loginResponse.json()
const token = loginData.data.token
// 2. 使用 token 访问受保护的资源
const meResponse = await fetch('http://localhost:7002/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
const userData = await meResponse.json()
console.log(userData.data) // 用户信息
```
### curl 示例
```bash
# 1. 登录
curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}'
# 2. 使用返回的 token 访问受保护资源
TOKEN="<your_jwt_token>"
curl http://localhost:7002/auth/me \
-H "Authorization: Bearer $TOKEN"
```
## 在其他模块中使用认证
### 使用认证中间件
项目提供了三个认证中间件:
#### 1. `authMiddleware` - 强制认证
要求请求必须包含有效的 Bearer token,否则返回 401。
```typescript
import Elysia from 'elysia'
import { authMiddleware } from './middlewares'
export const protectedModule = new Elysia({ prefix: '/protected' })
.use(authMiddleware)
.get('/data', ({ user }) => {
// user 包含: { userId, username, role }
return {
success: true,
message: `Hello ${user.username}!`,
}
})
```
#### 2. `optionalAuthMiddleware` - 可选认证
如果有 token 则验证,没有 token 也允许访问(user 为 null)。
```typescript
import Elysia from 'elysia'
import { optionalAuthMiddleware } from './middlewares'
export const publicModule = new Elysia({ prefix: '/public' })
.use(optionalAuthMiddleware)
.get('/data', ({ user }) => {
if (user) {
return { message: `Welcome back, ${user.username}!` }
}
return { message: 'Welcome, guest!' }
})
```
#### 3. `adminMiddleware` - 管理员权限
要求用户必须是 admin 角色,否则返回 403。
```typescript
import Elysia from 'elysia'
import { adminMiddleware } from './middlewares'
export const adminModule = new Elysia({ prefix: '/admin' })
.use(adminMiddleware)
.get('/users', ({ user }) => {
// 只有 admin 才能访问
return { success: true, data: [] }
})
```
## Root 用户说明
Root 用户是通过环境变量配置的超级管理员:
- **优先级最高**:登录时优先检查是否为 Root 用户
- **不存储在数据库**:直接通过环境变量验证
- **始终是 admin 角色**:拥有最高权限
- **用于系统初始化**:可用于创建其他管理员用户
## 数据库用户
除了 Root 用户外,系统支持在数据库中创建普通用户:
```typescript
import { createUser } from '@memohome/db/src/user-helpers'
// 创建新用户
const newUser = await createUser({
username: 'john_doe',
email: 'john@example.com',
passwordHash: await Bun.password.hash('password123'),
role: 'member',
displayName: 'John Doe',
})
```
用户密码使用 `Bun.password.hash` 加密存储,登录时使用 `Bun.password.verify` 验证。
## 安全建议
1. **生产环境配置**
- 使用强密码作为 `ROOT_USER_PASSWORD`
- 设置随机的 `JWT_SECRET`(至少 32 字符)
- 不要将 `.env` 文件提交到代码仓库
2. **Token 管理**
- Token 默认有效期为 7 天
- 前端应安全存储 token(如 httpOnly cookie 或安全的 localStorage
- Token 过期后需要重新登录
3. **密码策略**
- 要求用户使用强密码
- 考虑实现密码复杂度验证
- 考虑添加密码重置功能
## 技术栈
- **Elysia**Web 框架
- **@elysiajs/jwt**JWT token 生成和验证插件
- **@elysiajs/bearer**Bearer token 提取插件
- **Bun.password**:密码加密和验证(基于 bcrypt)
- **Drizzle ORM**:数据库操作
## 相关文档
- [Elysia JWT Plugin](https://elysiajs.com/plugins/jwt.html)
- [Elysia Bearer Plugin](https://elysiajs.com/plugins/bearer.html)
- [用户表设计文档](../db/USERS_SCHEMA.md)
+191
View File
@@ -1 +1,192 @@
# @memohome/api
API 服务器,基于 Elysia 构建。
## API 模块
### 认证模块 (`/auth`)
用户认证和授权管理。
- `POST /auth/login` - 用户登录
- `GET /auth/verify` - 验证 token
- `GET /auth/me` - 获取当前用户信息
详细文档:[AUTH_README.md](./AUTH_README.md)
### 用户管理模块 (`/user`) 🔒 仅管理员
完整的用户 CRUD 操作接口。
- `GET /user` - 获取所有用户
- `GET /user/:id` - 获取单个用户
- `POST /user` - 创建用户
- `PUT /user/:id` - 更新用户信息
- `DELETE /user/:id` - 删除用户
- `PATCH /user/:id/password` - 更新用户密码
详细文档:[USER_MANAGEMENT.md](./USER_MANAGEMENT.md)
### 模型管理模块 (`/model`)
AI 模型配置管理。
- `GET /model` - 获取所有模型
- `GET /model/:id` - 获取单个模型
- `POST /model` - 创建模型配置
- `PUT /model/:id` - 更新模型配置
- `DELETE /model/:id` - 删除模型配置
- `GET /model/chat/default` - 获取默认聊天模型
- `GET /model/summary/default` - 获取默认摘要模型
- `GET /model/embedding/default` - 获取默认嵌入模型
### 设置模块 (`/settings`) 🔒 需要认证
用户偏好设置管理(自动使用当前登录用户)。
- `GET /settings` - 获取当前用户设置
- `PUT /settings` - 更新当前用户设置(支持模型配置、Agent 参数等)
详细文档:[SETTINGS_API.md](./SETTINGS_API.md)
### 记忆模块 (`/memory`) 🔒 需要认证
用户记忆和对话历史管理(自动使用当前登录用户)。
- `POST /memory` - 添加记忆
- `GET /memory/search` - 搜索记忆
- `GET /memory/message` - 获取消息历史(分页)
- `GET /memory/message/filter` - 按日期范围过滤消息
详细文档:[API_CHANGES.md](./API_CHANGES.md)
### Agent 模块 (`/agent`) 🔒 需要认证
AI Agent 智能对话接口,支持流式响应和记忆管理。
- `POST /agent/stream` - 流式对话(Server-Sent Events
详细文档:[AGENT_API.md](./AGENT_API.md)
## 快速开始
### 安装依赖
```bash
pnpm install
```
### 配置环境变量
复制并编辑环境变量文件:
```bash
cp ../../.env.example ../../.env
```
必需配置:
- `DATABASE_URL` - PostgreSQL 连接字符串
- `ROOT_USER` - Root 超级管理员用户名
- `ROOT_USER_PASSWORD` - Root 超级管理员密码
- `JWT_SECRET` - JWT 签名密钥
### 启动开发服务器
```bash
pnpm run dev
```
服务器将在 `http://localhost:7002` 启动。
### 构建生产版本
```bash
pnpm run build
pnpm run start
```
## 认证和权限
### Bearer Token 认证
所有受保护的 API 端点需要在请求头中携带 JWT token:
```
Authorization: Bearer <your_jwt_token>
```
### 权限级别
- **公开接口**:无需认证
- **用户接口**:需要有效的 JWT token
- **管理员接口** 🔒:需要管理员角色的 JWT token
## 中间件
### `authMiddleware`
强制认证中间件,要求请求必须包含有效的 Bearer token。
```typescript
import { authMiddleware } from './middlewares'
const protectedModule = new Elysia()
.use(authMiddleware)
.get('/protected', ({ user }) => {
return { message: `Hello ${user.username}!` }
})
```
### `adminMiddleware` 🔒
管理员权限中间件,要求用户角色为 `admin`
```typescript
import { adminMiddleware } from './middlewares'
const adminModule = new Elysia()
.use(adminMiddleware)
.get('/admin-only', ({ user }) => {
return { message: 'Admin access granted' }
})
```
### `optionalAuthMiddleware`
可选认证中间件,如果有 token 则验证,没有则 `user``null`
```typescript
import { optionalAuthMiddleware } from './middlewares'
const publicModule = new Elysia()
.use(optionalAuthMiddleware)
.get('/public', ({ user }) => {
if (user) {
return { message: `Welcome back, ${user.username}!` }
}
return { message: 'Welcome, guest!' }
})
```
## 运行测试
```bash
pnpm test
```
## 技术栈
- **Elysia** - 高性能 Web 框架
- **@elysiajs/jwt** - JWT 认证插件
- **@elysiajs/bearer** - Bearer token 提取插件
- **@elysiajs/cors** - CORS 支持
- **Drizzle ORM** - 数据库 ORM
- **Zod** - 数据验证
- **Bun** - JavaScript 运行时
## 相关文档
- [认证系统](./AUTH_README.md)
- [用户管理](./USER_MANAGEMENT.md)
- [项目设置指南](../../SETUP.md)
- [数据库 Schema](../db/USERS_SCHEMA.md)
+281
View File
@@ -0,0 +1,281 @@
# Settings API 文档
## 概述
Settings API 用于管理用户的个性化设置,包括默认模型配置和 Agent 行为设置。
## 端点
### GET `/settings` 🔒 需要认证
获取当前用户的设置。
#### 请求
**Headers:**
```
Authorization: Bearer <your_jwt_token>
```
#### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"userId": "user-id",
"defaultChatModel": "123e4567-e89b-12d3-a456-426614174000",
"defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001",
"defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002",
"maxContextLoadTime": 60,
"language": "Chinese"
}
}
```
#### 错误响应 (404 Not Found)
```json
{
"success": false,
"error": "Settings not found"
}
```
---
### PUT `/settings` 🔒 需要认证
更新或创建当前用户的设置(Upsert 操作)。
#### 请求
**Headers:**
```
Authorization: Bearer <your_jwt_token>
Content-Type: application/json
```
**Body:**
```json
{
"defaultChatModel": "123e4567-e89b-12d3-a456-426614174000",
"defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001",
"defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002",
"maxContextLoadTime": 60,
"language": "Chinese"
}
```
**参数说明:**
| 字段 | 类型 | 必需 | 默认值 | 说明 |
|------|------|------|--------|------|
| defaultChatModel | string (uuid) | 否 | null | 默认聊天模型 ID |
| defaultEmbeddingModel | string (uuid) | 否 | null | 默认嵌入模型 ID |
| defaultSummaryModel | string (uuid) | 否 | null | 默认摘要模型 ID |
| maxContextLoadTime | number | 否 | 60 | Agent 加载上下文的时间范围(分钟,1-1440) |
| language | string | 否 | "Same as user input" | Agent 回复的首选语言 |
#### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"userId": "user-id",
"defaultChatModel": "123e4567-e89b-12d3-a456-426614174000",
"defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001",
"defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002",
"maxContextLoadTime": 60,
"language": "Chinese"
}
}
```
---
## 使用示例
### 完整工作流
```bash
# 1. 登录获取 token
TOKEN=$(curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_password"}' \
| jq -r '.data.token')
# 2. 获取当前设置
curl http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN"
# 3. 更新设置
curl -X PUT http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaultChatModel": "123e4567-e89b-12d3-a456-426614174000",
"defaultEmbeddingModel": "223e4567-e89b-12d3-a456-426614174001",
"defaultSummaryModel": "323e4567-e89b-12d3-a456-426614174002",
"maxContextLoadTime": 120,
"language": "English"
}'
# 4. 只更新部分字段
curl -X PUT http://localhost:7002/settings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"maxContextLoadTime": 30,
"language": "Chinese"
}'
```
### JavaScript/TypeScript 示例
```typescript
// 获取设置
async function getSettings(token: string) {
const response = await fetch('http://localhost:7002/settings', {
headers: {
'Authorization': `Bearer ${token}`,
},
})
const data = await response.json()
return data
}
// 更新设置
async function updateSettings(token: string, settings: {
defaultChatModel?: string
defaultEmbeddingModel?: string
defaultSummaryModel?: string
maxContextLoadTime?: number
language?: string
}) {
const response = await fetch('http://localhost:7002/settings', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
})
const data = await response.json()
return data
}
// 使用示例
const token = 'your_jwt_token'
// 获取当前设置
const currentSettings = await getSettings(token)
console.log(currentSettings)
// 更新 Agent 设置
await updateSettings(token, {
maxContextLoadTime: 90,
language: 'Chinese',
})
```
---
## Agent 设置说明
### maxContextLoadTime
控制 Agent 加载历史对话上下文的时间范围。
- **类型**: 整数(分钟)
- **范围**: 1-14401分钟到24小时)
- **默认值**: 601小时)
- **说明**:
- 值越大,Agent 能访问更久远的对话历史
- 但也会增加 token 使用量和响应时间
- 建议根据实际需求调整
**示例:**
- `30` - 加载最近30分钟的对话
- `60` - 加载最近1小时的对话(默认)
- `1440` - 加载最近24小时的对话
### language
设置 Agent 回复的首选语言。
- **类型**: 字符串
- **默认值**: "Same as user input"(与用户输入相同)
- **说明**:
- 可以设置为任何语言名称
- Agent 会尽量使用指定语言回复
- 特殊值 "Same as user input" 表示跟随用户输入语言
**常用值:**
- `"Same as user input"` - 自动匹配用户语言(默认)
- `"Chinese"` - 中文
- `"English"` - 英文
- `"Japanese"` - 日文
- `"Spanish"` - 西班牙语
---
## Agent 使用优先级
当使用 Agent API 时,配置的优先级为:
1. **请求参数** - `/agent/stream` 请求中直接指定的参数
2. **用户设置** - Settings 中保存的默认值
3. **系统默认** - 内置的默认值
**示例:**
```bash
# 情况1: 使用请求参数(最高优先级)
curl -X POST http://localhost:7002/agent/stream \
-H "Authorization: Bearer $TOKEN" \
-d '{
"message": "Hello",
"maxContextLoadTime": 30,
"language": "English"
}'
# 使用: maxContextLoadTime=30, language="English"
# 情况2: 使用用户设置(如果请求中未指定)
# 假设用户设置: maxContextLoadTime=60, language="Chinese"
curl -X POST http://localhost:7002/agent/stream \
-H "Authorization: Bearer $TOKEN" \
-d '{"message": "Hello"}'
# 使用: maxContextLoadTime=60, language="Chinese"
# 情况3: 使用系统默认(如果都未设置)
# 使用: maxContextLoadTime=60, language="Same as user input"
```
---
## 数据库 Schema
```sql
CREATE TABLE settings (
user_id TEXT PRIMARY KEY,
default_chat_model UUID REFERENCES model(id),
default_embedding_model UUID REFERENCES model(id),
default_summary_model UUID REFERENCES model(id),
max_context_load_time INTEGER DEFAULT 60,
language TEXT DEFAULT 'Same as user input'
);
```
---
## 相关文档
- [Agent API](./AGENT_API.md)
- [Model API](./README.md)
- [认证系统](./AUTH_README.md)
- [API 变更说明](./API_CHANGES.md)
+480
View File
@@ -0,0 +1,480 @@
# 用户管理 API 文档
## 概述
用户管理模块提供了完整的用户 CRUD 操作接口,**仅限管理员访问**。所有端点都需要携带有效的管理员 JWT token。
## 权限要求
所有用户管理接口都受 `adminMiddleware` 保护:
- 必须提供有效的 Bearer token
- 用户角色必须是 `admin`
- 否则返回 `403 Forbidden` 错误
## API 端点
### 基础 URL
```
http://localhost:7002/user
```
### 请求头
所有请求都需要包含认证 token
```
Authorization: Bearer <your_admin_jwt_token>
```
---
## 1. 获取所有用户 GET `/user`
获取系统中所有用户的列表。
### 请求示例
```bash
curl http://localhost:7002/user \
-H "Authorization: Bearer <admin_token>"
```
### 成功响应 (200 OK)
```json
{
"success": true,
"data": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"username": "john_doe",
"email": "john@example.com",
"role": "member",
"displayName": "John Doe",
"avatarUrl": "https://example.com/avatar.jpg",
"isActive": "true",
"createdAt": "2024-01-10T10:00:00Z",
"updatedAt": "2024-01-10T10:00:00Z",
"lastLoginAt": "2024-01-10T12:00:00Z"
}
]
}
```
---
## 2. 获取单个用户 GET `/user/:id`
根据用户 ID 获取用户详细信息。
### 路径参数
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| id | UUID | 是 | 用户的唯一标识符 |
### 请求示例
```bash
curl http://localhost:7002/user/123e4567-e89b-12d3-a456-426614174000 \
-H "Authorization: Bearer <admin_token>"
```
### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"username": "john_doe",
"email": "john@example.com",
"role": "member",
"displayName": "John Doe",
"avatarUrl": "https://example.com/avatar.jpg",
"isActive": "true",
"createdAt": "2024-01-10T10:00:00Z",
"updatedAt": "2024-01-10T10:00:00Z",
"lastLoginAt": "2024-01-10T12:00:00Z"
}
}
```
### 错误响应 (404 Not Found)
```json
{
"success": false,
"error": "User not found"
}
```
---
## 3. 创建用户 POST `/user`
创建新用户账户。
### 请求体
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| username | string | 是 | 用户名(3-50字符,唯一) |
| email | string | 否 | 邮箱地址(必须是有效格式,唯一) |
| password | string | 是 | 密码(至少6个字符) |
| role | string | 否 | 用户角色:`admin``member`(默认 `member` |
| displayName | string | 否 | 显示名称 |
| avatarUrl | string | 否 | 头像 URL(必须是有效的 URL) |
### 请求示例
```bash
curl -X POST http://localhost:7002/user \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"username": "jane_smith",
"email": "jane@example.com",
"password": "secure_password123",
"role": "member",
"displayName": "Jane Smith"
}'
```
### 成功响应 (201 Created)
```json
{
"success": true,
"data": {
"id": "223e4567-e89b-12d3-a456-426614174001",
"username": "jane_smith",
"email": "jane@example.com",
"role": "member",
"displayName": "Jane Smith",
"avatarUrl": null,
"isActive": "true",
"createdAt": "2024-01-10T15:00:00Z"
}
}
```
**注意**: 创建用户时会自动创建一个默认的 settings 条目,包含:
- `maxContextLoadTime`: 60(分钟)
- `language`: "Same as user input"
- 所有模型配置为 `null`(需要用户后续配置)
### 错误响应 (409 Conflict)
```json
{
"success": false,
"error": "Username already exists"
}
```
```json
{
"success": false,
"error": "Email already exists"
}
```
---
## 4. 更新用户 PUT `/user/:id`
更新用户信息(不包括密码)。
### 路径参数
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| id | UUID | 是 | 用户的唯一标识符 |
### 请求体(所有字段都是可选的)
| 字段 | 类型 | 说明 |
|------|------|------|
| email | string | 邮箱地址 |
| role | string | 用户角色:`admin``member` |
| displayName | string | 显示名称 |
| avatarUrl | string | 头像 URL |
| isActive | string | 账户状态:`true``false` |
### 请求示例
```bash
curl -X PUT http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001 \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Jane Smith (Updated)",
"role": "admin",
"isActive": "true"
}'
```
### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"id": "223e4567-e89b-12d3-a456-426614174001",
"username": "jane_smith",
"email": "jane@example.com",
"role": "admin",
"displayName": "Jane Smith (Updated)",
"avatarUrl": null,
"isActive": "true",
"createdAt": "2024-01-10T15:00:00Z",
"updatedAt": "2024-01-10T16:00:00Z",
"lastLoginAt": null
}
}
```
### 错误响应 (404 Not Found)
```json
{
"success": false,
"error": "User not found"
}
```
---
## 5. 删除用户 DELETE `/user/:id`
删除用户账户。
### 路径参数
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| id | UUID | 是 | 用户的唯一标识符 |
### 请求示例
```bash
curl -X DELETE http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001 \
-H "Authorization: Bearer <admin_token>"
```
### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"id": "223e4567-e89b-12d3-a456-426614174001",
"username": "jane_smith"
}
}
```
### 错误响应 (404 Not Found)
```json
{
"success": false,
"error": "User not found"
}
```
---
## 6. 更新用户密码 PATCH `/user/:id/password`
重置或更新用户密码。
### 路径参数
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| id | UUID | 是 | 用户的唯一标识符 |
### 请求体
| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| password | string | 是 | 新密码(至少6个字符) |
### 请求示例
```bash
curl -X PATCH http://localhost:7002/user/223e4567-e89b-12d3-a456-426614174001/password \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"password": "new_secure_password456"
}'
```
### 成功响应 (200 OK)
```json
{
"success": true,
"data": {
"id": "223e4567-e89b-12d3-a456-426614174001",
"username": "jane_smith"
},
"message": "Password updated successfully"
}
```
### 错误响应 (404 Not Found)
```json
{
"success": false,
"error": "User not found"
}
```
---
## 错误响应
### 401 Unauthorized
未提供 token 或 token 无效:
```json
{
"success": false,
"error": "No bearer token provided"
}
```
### 403 Forbidden
非管理员用户尝试访问:
```json
{
"success": false,
"error": "Forbidden: Admin access required"
}
```
### 400 Bad Request
请求数据验证失败:
```json
{
"success": false,
"error": "Username must be at least 3 characters"
}
```
---
## 使用示例
### 完整工作流
```bash
# 1. 管理员登录获取 token
TOKEN=$(curl -X POST http://localhost:7002/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"your_admin_password"}' \
| jq -r '.data.token')
# 2. 创建新用户
curl -X POST http://localhost:7002/user \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"username": "new_user",
"email": "newuser@example.com",
"password": "password123",
"role": "member",
"displayName": "New User"
}'
# 3. 获取所有用户
curl http://localhost:7002/user \
-H "Authorization: Bearer $TOKEN"
# 4. 获取特定用户
USER_ID="123e4567-e89b-12d3-a456-426614174000"
curl http://localhost:7002/user/$USER_ID \
-H "Authorization: Bearer $TOKEN"
# 5. 更新用户信息
curl -X PUT http://localhost:7002/user/$USER_ID \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Updated Name",
"role": "admin"
}'
# 6. 重置用户密码
curl -X PATCH http://localhost:7002/user/$USER_ID/password \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"password": "new_password123"
}'
# 7. 删除用户
curl -X DELETE http://localhost:7002/user/$USER_ID \
-H "Authorization: Bearer $TOKEN"
```
---
## 自动化行为
### 创建用户时的自动操作
当创建新用户时,系统会自动执行以下操作:
1. **密码加密**: 使用 bcrypt 算法加密密码
2. **创建 Settings**: 自动为用户创建默认设置条目
- `maxContextLoadTime`: 60 分钟
- `language`: "Same as user input"
- 模型配置: 全部为 null(需要用户后续配置)
这确保每个用户都有完整的配置,可以立即使用系统功能。
---
## 安全注意事项
1. **管理员权限**
- 所有接口仅限管理员访问
- 确保 Root 用户的密码强度足够
- 定期审计管理员账户
2. **密码安全**
- 密码使用 bcrypt 加密存储
- 最少 6 个字符(建议更高要求)
- 重置密码后建议通知用户
3. **数据验证**
- 所有输入都经过严格验证
- 邮箱和用户名必须唯一
- URL 格式必须有效
4. **用户状态**
- 使用 `isActive` 字段禁用用户而非直接删除
- 保留用户数据用于审计
---
## 相关文档
- [认证系统文档](./AUTH_README.md)
- [用户表设计](../db/USERS_SCHEMA.md)
- [项目设置指南](../../SETUP.md)
@@ -0,0 +1,164 @@
/**
* Agent Stream Client Example
*
* This example demonstrates how to use the /agent/stream API endpoint
* to have a streaming conversation with the AI agent.
*
* Usage:
* bun run agent-stream-client.ts
*/
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:7002'
interface LoginResponse {
success: boolean
data?: {
token: string
user: {
id: string
username: string
role: string
}
}
error?: string
}
async function login(username: string, password: string): Promise<string> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data: LoginResponse = await response.json()
if (!data.success || !data.data?.token) {
throw new Error(data.error || 'Login failed')
}
return data.data.token
}
async function streamAgentChat(message: string, token: string) {
console.log(`\n📤 User: ${message}`)
console.log('🤖 Agent: ', { end: '' })
const response = await fetch(`${API_BASE_URL}/agent/stream`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
maxContextLoadTime: 60,
language: 'Same as user input',
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Stream request failed')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
throw new Error('No reader available')
}
let hasOutput = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') {
console.log('\n\n✅ Stream completed')
return
}
if (!data) continue
try {
const event = JSON.parse(data)
if (event.type === 'text-delta' && event.text) {
process.stdout.write(event.text)
hasOutput = true
} else if (event.type === 'tool-call') {
console.log(`\n[🔧 Tool: ${event.toolName}]`)
hasOutput = true
} else if (event.type === 'error') {
console.error('\n❌ Error:', event.error)
throw new Error(event.error)
}
} catch (e) {
if (e instanceof SyntaxError) {
// Skip invalid JSON
continue
}
throw e
}
}
}
}
} finally {
reader.releaseLock()
}
if (!hasOutput) {
console.log('(No response)')
}
}
async function main() {
try {
// Get credentials from environment or use defaults
const username = process.env.USERNAME || 'admin'
const password = process.env.PASSWORD || 'admin'
console.log('🔐 Logging in...')
const token = await login(username, password)
console.log('✅ Login successful')
// Example conversations
const messages = [
'你好,请介绍一下你自己',
'你能帮我做什么?',
'记住:我的名字是张三',
'我的名字是什么?',
]
for (const message of messages) {
await streamAgentChat(message, token)
// Wait a bit between messages
await new Promise(resolve => setTimeout(resolve, 1000))
}
console.log('\n🎉 All conversations completed!')
} catch (error) {
console.error('\n❌ Error:', error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
// Handle Ctrl+C gracefully
process.on('SIGINT', () => {
console.log('\n\n👋 Goodbye!')
process.exit(0)
})
main()
+2
View File
@@ -11,9 +11,11 @@
"./client": "./src/client.ts"
},
"dependencies": {
"@elysiajs/bearer": "^1.1.4",
"@elysiajs/cors": "^1.4.1",
"@elysiajs/cron": "^1.4.1",
"@elysiajs/eden": "^1.4.6",
"@elysiajs/jwt": "^1.2.0",
"@memohome/agent": "workspace:*",
"@memohome/db": "workspace:*",
"@memohome/memory": "workspace:*",
+3 -1
View File
@@ -1,16 +1,18 @@
import { Elysia } from 'elysia'
import { corsMiddleware } from './middlewares'
import { agentModule, modelModule, settingsModule } from './modules'
import { agentModule, authModule, modelModule, settingsModule, userModule } from './modules'
import { memoryModule } from './modules/memory'
const port = process.env.API_SERVER_PORT || 7002
export const app = new Elysia()
.use(corsMiddleware)
.use(authModule)
.use(agentModule)
.use(memoryModule)
.use(modelModule)
.use(settingsModule)
.use(userModule)
.listen(port)
console.log(
+113
View File
@@ -0,0 +1,113 @@
import { Elysia } from 'elysia'
import { bearer } from '@elysiajs/bearer'
import { jwt } from '@elysiajs/jwt'
/**
* 认证中间件
* 验证 Bearer token 并将用户信息注入到 context 中
*/
export const authMiddleware = new Elysia({ name: '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())
.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,
},
}
})
/**
* 可选认证中间件
* 如果有 token 则验证,没有 token 则继续(user 为 null
*/
export const optionalAuthMiddleware = new Elysia({ name: 'optional-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())
.derive(async ({ bearer, jwt }) => {
if (!bearer) {
return { user: null }
}
const payload = await jwt.verify(bearer)
if (!payload) {
return { user: null }
}
return {
user: {
userId: payload.userId as string,
username: payload.username as string,
role: payload.role as string,
},
}
})
/**
* 管理员权限中间件
* 验证 token 并检查用户是否为管理员
*/
export const adminMiddleware = new Elysia({ name: 'admin' })
.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')
}
const user = {
userId: payload.userId as string,
username: payload.username as string,
role: payload.role as string,
}
// 检查是否为管理员
if (user.role !== 'admin') {
set.status = 403
throw new Error('Forbidden: Admin access required')
}
return { user }
})
+1
View File
@@ -1 +1,2 @@
export * from './auth'
export * from './cors'
+120
View File
@@ -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)
+13
View File
@@ -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']>
+63
View File
@@ -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
}
+135
View File
@@ -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',
}
}
})
+13
View File
@@ -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,
}
+86
View File
@@ -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,
}
}
+2
View File
@@ -1,3 +1,5 @@
export * from './agent'
export * from './auth'
export * from './model'
export * from './settings'
export * from './user'
+43 -6
View File
@@ -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'
@@ -6,32 +8,67 @@ 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,
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)
+4 -6
View File
@@ -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'),
}),
}
+44 -57
View File
@@ -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)
+5 -21
View File
@@ -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,
}
+21 -34
View File
@@ -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
+171
View File
@@ -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)
+63
View File
@@ -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,
}
+213
View File
@@ -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
}
+13
View File
@@ -1,5 +1,18 @@
# @memohome/db
Database package for memohome project using Drizzle ORM and PostgreSQL.
## Database Schema
### Tables
- **model**: AI model configurations
- **history**: Chat history records
- **settings**: User settings and preferences
- **users**: User accounts with authentication
See [USERS_SCHEMA.md](./USERS_SCHEMA.md) for detailed user table documentation.
## Quick Start
### Initialize Database
+120
View File
@@ -0,0 +1,120 @@
# 用户表设计
## 数据库表结构
### users 表(用户表)
存储用户的基本信息和登录凭证。
| 字段名 | 类型 | 说明 | 约束 |
|--------|------|------|------|
| id | uuid | 用户唯一标识 | 主键,自动生成 |
| username | text | 用户名 | 非空,唯一 |
| email | text | 邮箱地址 | 唯一(可选)|
| password_hash | text | 密码哈希值 | 非空 |
| role | user_role | 用户角色 | 枚举:admin/member,默认 member |
| display_name | text | 显示名称 | 可选 |
| avatar_url | text | 头像地址 | 可选 |
| is_active | text | 账户状态 | 默认 true |
| created_at | timestamp | 创建时间 | 自动生成 |
| updated_at | timestamp | 更新时间 | 自动生成 |
| last_login_at | timestamp | 最后登录时间 | 可选 |
## 角色说明
系统定义了两种角色:
- **admin**: 管理员,拥有完整权限
- **member**: 普通成员,拥有基础权限
## 使用方法
### 1. 生成迁移文件
```bash
cd packages/db
pnpm run generate
```
### 2. 执行数据库迁移
```bash
pnpm run push
```
### 3. 在代码中使用
```typescript
import { users, userRoleEnum } from '@memohome/db/schema'
import { db } from '@memohome/db'
// 创建用户
const password = 'password123'
const passwordHash = await Bun.password.hash(password) // 使用 Bun 内置的密码加密
const newUser = await db.insert(users).values({
username: 'john_doe',
email: 'john@example.com',
passwordHash: passwordHash,
role: 'member',
displayName: 'John Doe',
}).returning()
// 或者使用辅助函数
import { createUser } from '@memohome/db/src/user-helpers'
const user = await createUser({
username: 'john_doe',
email: 'john@example.com',
passwordHash: await Bun.password.hash('password123'),
role: 'member',
displayName: 'John Doe',
})
```
## Root 用户
系统支持通过环境变量配置超级用户(Root User):
```bash
ROOT_USER=admin
ROOT_USER_PASSWORD=your_secure_password
```
Root 用户特点:
- 不存储在数据库中
- 通过环境变量直接验证
- 始终拥有 admin 角色
- 用于系统初始化和紧急管理
## 安全建议
1. **密码存储**:
- 永远不要存储明文密码
- 使用 Bun.password.hash 进行密码加密
- Bun 内置的密码加密基于 bcrypt 算法,安全可靠
2. **身份验证**:
- 使用 JWT 进行用户身份验证
- 设置合理的过期时间(默认 7 天)
- 在请求头中携带 `Authorization: Bearer <token>`
3. **权限控制**:
- 在业务层实现基于角色的访问控制(RBAC)
- 验证用户权限后再执行敏感操作
- 记录重要操作的审计日志
4. **环境变量**:
- 设置强密码作为 ROOT_USER_PASSWORD
- 在生产环境中设置自定义的 JWT_SECRET
- 不要将敏感信息提交到代码仓库
## 扩展建议
如果需要更复杂的功能,可以考虑:
1. 添加用户注册功能
2. 添加密码重置功能
3. 添加邮箱验证功能
4. 实现刷新令牌机制
5. 添加用户会话管理
+1
View File
@@ -1,3 +1,4 @@
export * from './history'
export * from './model'
export * from './settings'
export * from './users'
+4 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, text, uuid } from 'drizzle-orm/pg-core'
import { pgTable, text, uuid, integer } from 'drizzle-orm/pg-core'
import { model } from './model'
export const settings = pgTable('settings', {
@@ -6,4 +6,7 @@ export const settings = pgTable('settings', {
defaultChatModel: uuid('default_chat_model').references(() => model.id),
defaultEmbeddingModel: uuid('default_embedding_model').references(() => model.id),
defaultSummaryModel: uuid('default_summary_model').references(() => model.id),
// Agent settings
maxContextLoadTime: integer('max_context_load_time').default(60), // minutes
language: text('language').default('Same as user input'),
})
+112
View File
@@ -0,0 +1,112 @@
import { eq } from 'drizzle-orm'
import { db } from './index'
import { users } from './users'
/**
*
*
*/
// ============= 用户操作 =============
/**
*
*/
export async function findUserByUsername(username: string) {
const result = await db.select().from(users).where(eq(users.username, username))
return result[0] || null
}
/**
*
*/
export async function findUserByEmail(email: string) {
const result = await db.select().from(users).where(eq(users.email, email))
return result[0] || null
}
/**
* ID查找用户
*/
export async function findUserById(id: string) {
const result = await db.select().from(users).where(eq(users.id, id))
return result[0] || null
}
/**
*
*/
export async function createUser(data: {
username: string
email?: string
passwordHash: string
role?: 'admin' | 'member'
displayName?: string
avatarUrl?: string
}) {
const result = await db.insert(users).values({
username: data.username,
email: data.email,
passwordHash: data.passwordHash,
role: data.role || 'member',
displayName: data.displayName,
avatarUrl: data.avatarUrl,
}).returning()
return result[0]
}
/**
*
*/
export async function updateUser(id: string, data: {
displayName?: string
avatarUrl?: string
email?: string
role?: 'admin' | 'member'
isActive?: string
}) {
const result = await db
.update(users)
.set({
...data,
updatedAt: new Date(),
})
.where(eq(users.id, id))
.returning()
return result[0]
}
/**
*
*/
export async function updateUserPassword(id: string, passwordHash: string) {
await db
.update(users)
.set({
passwordHash,
updatedAt: new Date(),
})
.where(eq(users.id, id))
}
/**
*
*/
export async function updateLastLogin(id: string) {
await db
.update(users)
.set({
lastLoginAt: new Date(),
})
.where(eq(users.id, id))
}
/**
*
*/
export async function deleteUser(id: string) {
await db.delete(users).where(eq(users.id, id))
}
+40
View File
@@ -0,0 +1,40 @@
import { pgTable, pgEnum, text, timestamp, uuid } from 'drizzle-orm/pg-core'
// 定义用户角色枚举
export const userRoleEnum = pgEnum('user_role', ['admin', 'member'])
// 用户表
export const users = pgTable('users', {
// 主键ID
id: uuid('id').primaryKey().defaultRandom(),
// 用户名(唯一)
username: text('username').notNull().unique(),
// 邮箱(可选,唯一)
email: text('email').unique(),
// 密码哈希值(使用 bcrypt 或其他加密方式)
passwordHash: text('password_hash').notNull(),
// 用户角色
role: userRoleEnum('role').notNull().default('member'),
// 显示名称
displayName: text('display_name'),
// 头像 URL
avatarUrl: text('avatar_url'),
// 账户状态(是否激活)
isActive: text('is_active').notNull().default('true'),
// 创建时间
createdAt: timestamp('created_at').notNull().defaultNow(),
// 更新时间
updatedAt: timestamp('updated_at').notNull().defaultNow(),
// 最后登录时间
lastLoginAt: timestamp('last_login_at'),
})
+33
View File
@@ -90,6 +90,9 @@ importers:
packages/api:
dependencies:
'@elysiajs/bearer':
specifier: ^1.1.4
version: 1.4.2(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/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))
@@ -99,6 +102,9 @@ importers:
'@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))
'@elysiajs/jwt':
specifier: ^1.2.0
version: 1.4.0(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
@@ -260,6 +266,9 @@ importers:
packages/web:
dependencies:
'@memohome/api':
specifier: workspace:*
version: link:../api
'@memohome/shared':
specifier: workspace:*
version: link:../shared
@@ -557,6 +566,11 @@ packages:
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@elysiajs/bearer@1.4.2':
resolution: {integrity: sha512-MK2aCFqnFMqMNSa1e/A6+Ow5uNl5LpKd8K4lCB2LIsyDrI6juxOUHAgqq+esgdSoh3urD1UIMqFC//TsqCQViA==}
peerDependencies:
elysia: '>= 1.4.3'
'@elysiajs/cors@1.4.1':
resolution: {integrity: sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ==}
peerDependencies:
@@ -572,6 +586,11 @@ packages:
peerDependencies:
elysia: '>=1.4.19'
'@elysiajs/jwt@1.4.0':
resolution: {integrity: sha512-Z0PvZhQxdDeKZ8HslXzDoXXD83NKExNPmoiAPki3nI2Xvh5wtUrBH+zWOD17yP14IbRo8fxGj3L25MRCAPsgPA==}
peerDependencies:
elysia: '>= 1.4.0'
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
@@ -2924,6 +2943,9 @@ packages:
jju@1.4.0:
resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
@@ -4648,6 +4670,10 @@ snapshots:
'@drizzle-team/brocli@0.10.2': {}
'@elysiajs/bearer@1.4.2(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/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)
@@ -4661,6 +4687,11 @@ snapshots:
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/jwt@1.4.0(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)
jose: 6.1.3
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -6960,6 +6991,8 @@ snapshots:
jju@1.4.0: {}
jose@6.1.3: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1