308 lines
6.9 KiB
Markdown
308 lines
6.9 KiB
Markdown
# Convex Examples
|
|
|
|
Reference implementations for common Convex patterns.
|
|
|
|
---
|
|
|
|
## Example: Chat App with AI Responses
|
|
|
|
A real-time chat backend demonstrating:
|
|
- User and channel management
|
|
- Message storage with proper ordering
|
|
- OpenAI integration for AI responses
|
|
- Background job scheduling
|
|
|
|
### Task Requirements
|
|
- Allow creating users with names
|
|
- Support multiple chat channels
|
|
- Enable users to send messages to channels
|
|
- Automatically generate AI responses to user messages
|
|
- Show recent message history (10 most recent per channel)
|
|
|
|
### API Design
|
|
|
|
**Public Mutations:**
|
|
- `createUser` - Create user with name
|
|
- `createChannel` - Create channel with name
|
|
- `sendMessage` - Send message and trigger AI response
|
|
|
|
**Public Queries:**
|
|
- `listMessages` - Get 10 most recent messages (descending)
|
|
|
|
**Internal Functions:**
|
|
- `generateResponse` - Call OpenAI API
|
|
- `loadContext` - Load message history for AI context
|
|
- `writeAgentResponse` - Save AI response to database
|
|
|
|
### Schema Design
|
|
```
|
|
users: { name: string }
|
|
channels: { name: string }
|
|
messages: { channelId, authorId?, content } + index by_channel
|
|
```
|
|
|
|
---
|
|
|
|
### Implementation
|
|
|
|
#### package.json
|
|
```json
|
|
{
|
|
"name": "chat-app",
|
|
"version": "1.0.0",
|
|
"dependencies": {
|
|
"convex": "^1.31.2",
|
|
"openai": "^4.79.0"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "^5.7.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### convex/schema.ts
|
|
```typescript
|
|
import { defineSchema, defineTable } from "convex/server";
|
|
import { v } from "convex/values";
|
|
|
|
export default defineSchema({
|
|
channels: defineTable({
|
|
name: v.string(),
|
|
}),
|
|
|
|
users: defineTable({
|
|
name: v.string(),
|
|
}),
|
|
|
|
messages: defineTable({
|
|
channelId: v.id("channels"),
|
|
authorId: v.optional(v.id("users")),
|
|
content: v.string(),
|
|
}).index("by_channel", ["channelId"]),
|
|
});
|
|
```
|
|
|
|
#### convex/index.ts
|
|
```typescript
|
|
import {
|
|
query,
|
|
mutation,
|
|
internalQuery,
|
|
internalMutation,
|
|
internalAction,
|
|
} from "./_generated/server";
|
|
import { v } from "convex/values";
|
|
import OpenAI from "openai";
|
|
import { internal } from "./_generated/api";
|
|
|
|
/**
|
|
* Create a user with a given name.
|
|
*/
|
|
export const createUser = mutation({
|
|
args: {
|
|
name: v.string(),
|
|
},
|
|
returns: v.id("users"),
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.insert("users", { name: args.name });
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Create a channel with a given name.
|
|
*/
|
|
export const createChannel = mutation({
|
|
args: {
|
|
name: v.string(),
|
|
},
|
|
returns: v.id("channels"),
|
|
handler: async (ctx, args) => {
|
|
return await ctx.db.insert("channels", { name: args.name });
|
|
},
|
|
});
|
|
|
|
/**
|
|
* List the 10 most recent messages from a channel in descending creation order.
|
|
*/
|
|
export const listMessages = query({
|
|
args: {
|
|
channelId: v.id("channels"),
|
|
},
|
|
returns: v.array(
|
|
v.object({
|
|
_id: v.id("messages"),
|
|
_creationTime: v.number(),
|
|
channelId: v.id("channels"),
|
|
authorId: v.optional(v.id("users")),
|
|
content: v.string(),
|
|
}),
|
|
),
|
|
handler: async (ctx, args) => {
|
|
const messages = await ctx.db
|
|
.query("messages")
|
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
|
.order("desc")
|
|
.take(10);
|
|
return messages;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Send a message to a channel and schedule a response from the AI.
|
|
*/
|
|
export const sendMessage = mutation({
|
|
args: {
|
|
channelId: v.id("channels"),
|
|
authorId: v.id("users"),
|
|
content: v.string(),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const channel = await ctx.db.get(args.channelId);
|
|
if (!channel) {
|
|
throw new Error("Channel not found");
|
|
}
|
|
const user = await ctx.db.get(args.authorId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
await ctx.db.insert("messages", {
|
|
channelId: args.channelId,
|
|
authorId: args.authorId,
|
|
content: args.content,
|
|
});
|
|
await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
|
|
channelId: args.channelId,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const openai = new OpenAI();
|
|
|
|
export const generateResponse = internalAction({
|
|
args: {
|
|
channelId: v.id("channels"),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
const context = await ctx.runQuery(internal.index.loadContext, {
|
|
channelId: args.channelId,
|
|
});
|
|
const response = await openai.chat.completions.create({
|
|
model: "gpt-4o",
|
|
messages: context,
|
|
});
|
|
const content = response.choices[0].message.content;
|
|
if (!content) {
|
|
throw new Error("No content in response");
|
|
}
|
|
await ctx.runMutation(internal.index.writeAgentResponse, {
|
|
channelId: args.channelId,
|
|
content,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
|
|
export const loadContext = internalQuery({
|
|
args: {
|
|
channelId: v.id("channels"),
|
|
},
|
|
returns: v.array(
|
|
v.object({
|
|
role: v.union(v.literal("user"), v.literal("assistant")),
|
|
content: v.string(),
|
|
}),
|
|
),
|
|
handler: async (ctx, args) => {
|
|
const channel = await ctx.db.get(args.channelId);
|
|
if (!channel) {
|
|
throw new Error("Channel not found");
|
|
}
|
|
const messages = await ctx.db
|
|
.query("messages")
|
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
|
.order("desc")
|
|
.take(10);
|
|
|
|
const result = [];
|
|
for (const message of messages) {
|
|
if (message.authorId) {
|
|
const user = await ctx.db.get(message.authorId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
result.push({
|
|
role: "user" as const,
|
|
content: `${user.name}: ${message.content}`,
|
|
});
|
|
} else {
|
|
result.push({ role: "assistant" as const, content: message.content });
|
|
}
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
export const writeAgentResponse = internalMutation({
|
|
args: {
|
|
channelId: v.id("channels"),
|
|
content: v.string(),
|
|
},
|
|
returns: v.null(),
|
|
handler: async (ctx, args) => {
|
|
await ctx.db.insert("messages", {
|
|
channelId: args.channelId,
|
|
content: args.content,
|
|
});
|
|
return null;
|
|
},
|
|
});
|
|
```
|
|
|
|
#### convex/tsconfig.json
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"allowJs": true,
|
|
"strict": true,
|
|
"moduleResolution": "Bundler",
|
|
"jsx": "react-jsx",
|
|
"skipLibCheck": true,
|
|
"allowSyntheticDefaultImports": true,
|
|
"target": "ESNext",
|
|
"lib": ["ES2021", "dom"],
|
|
"forceConsistentCasingInFileNames": true,
|
|
"module": "ESNext",
|
|
"isolatedModules": true,
|
|
"noEmit": true
|
|
},
|
|
"include": ["./**/*"],
|
|
"exclude": ["./_generated"]
|
|
}
|
|
```
|
|
|
|
#### tsconfig.json (root)
|
|
```json
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ESNext",
|
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
"skipLibCheck": true,
|
|
"allowSyntheticDefaultImports": true,
|
|
"strict": true,
|
|
"forceConsistentCasingInFileNames": true,
|
|
"module": "ESNext",
|
|
"moduleResolution": "Bundler",
|
|
"resolveJsonModule": true,
|
|
"isolatedModules": true,
|
|
"allowImportingTsExtensions": true,
|
|
"noEmit": true,
|
|
"jsx": "react-jsx"
|
|
},
|
|
"exclude": ["convex"],
|
|
"include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
|
|
}
|
|
```
|