diff --git a/.cursor/rules/convex_rules.mdc b/.cursor/rules/convex_rules.mdc index 1a1662e..4693e56 100644 --- a/.cursor/rules/convex_rules.mdc +++ b/.cursor/rules/convex_rules.mdc @@ -1,6 +1,7 @@ --- description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples -globs: **/*.{ts,tsx,js,jsx} +globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx +alwaysApply: false --- # Convex guidelines @@ -85,6 +86,19 @@ globs: **/*.{ts,tsx,js,jsx} }, }); ``` +- Here are the valid Convex types along with their respective validators: + Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | +| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Id | string | `doc._id` | `v.id(tableName)` | | +| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | +| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | +| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | +| Boolean | boolean | `true` | `v.boolean()` | +| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | +| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | +| Array | Array] | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | +| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "_". | ### Function registration - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. @@ -152,6 +166,9 @@ globs: **/*.{ts,tsx,js,jsx} }, }); ``` + Note: `paginationOpts` is an object with the following properties: + - `numItems`: the maximum number of documents to return (the validator is `v.number()`) + - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) - A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) @@ -165,7 +182,7 @@ globs: **/*.{ts,tsx,js,jsx} ## Schema guidelines - Always define your schema in `convex/schema.ts`. - Always import the schema definition functions from `convex/server`: -- System fields are automatically added to all documents and are prefixed with an underscore. +- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. @@ -301,13 +318,13 @@ const messages = await ctx.db # Examples: -## Example: thread-app +## Example: chat-app ### Task ``` -Create a real-time thread application backend with AI responses. The app should: +Create a real-time chat application backend with AI responses. The app should: - Allow creating users with names -- Support multiple thread channels +- Support multiple chat channels - Enable users to send messages to channels - Automatically generate AI responses to user messages - Show recent message history @@ -325,7 +342,7 @@ and limit history display to the 10 most recent messages per channel. ### Analysis 1. Task Requirements Summary: -- Build a real-time thread backend with AI integration +- Build a real-time chat backend with AI integration - Support user creation - Enable channel-based conversations - Store and retrieve messages with proper ordering @@ -414,8 +431,8 @@ Internal Functions: #### package.json ```typescript { - "name": "thread-app", - "description": "This example shows how to build a thread app without authentication.", + "name": "chat-app", + "description": "This example shows how to build a chat app without authentication.", "version": "1.0.0", "dependencies": { "convex": "^1.17.4", diff --git a/example/convex/_generated/api.d.ts b/example/convex/_generated/api.d.ts index 2ecfd48..f84ac63 100644 --- a/example/convex/_generated/api.d.ts +++ b/example/convex/_generated/api.d.ts @@ -12,6 +12,7 @@ import type * as example from "../example.js"; import type * as http from "../http.js"; import type * as ideaAgents from "../ideaAgents.js"; import type * as ideas from "../ideas.js"; +import type * as playground from "../playground.js"; import type * as weather from "../weather.js"; import type { @@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{ http: typeof http; ideaAgents: typeof ideaAgents; ideas: typeof ideas; + playground: typeof playground; weather: typeof weather; }>; declare const fullApiWithMounts: typeof fullApi; diff --git a/example/convex/example.ts b/example/convex/example.ts index 9308bc3..cfcfd16 100644 --- a/example/convex/example.ts +++ b/example/convex/example.ts @@ -15,7 +15,7 @@ const usageHandler: UsageHandler = async (_ctx, args) => { }; // Define an agent similarly to the AI SDK -const weatherAgent = new Agent(components.agent, { +export const weatherAgent = new Agent(components.agent, { name: "Weather Agent", chat: openai.chat("gpt-4o-mini"), textEmbedding: openai.embedding("text-embedding-3-small"), @@ -29,7 +29,7 @@ const weatherAgent = new Agent(components.agent, { usageHandler, }); -const fashionAgent = new Agent(components.agent, { +export const fashionAgent = new Agent(components.agent, { name: "Fashion Agent", chat: openai.chat("gpt-4o-mini"), textEmbedding: openai.embedding("text-embedding-3-small"), diff --git a/example/convex/playground.ts b/example/convex/playground.ts new file mode 100644 index 0000000..65d4d23 --- /dev/null +++ b/example/convex/playground.ts @@ -0,0 +1,23 @@ +import { definePlaygroundAPI } from "@convex-dev/agent/playground"; +import { components } from "./_generated/api"; +import { weatherAgent, fashionAgent } from "./example"; + +/** + * Here we expose the API so the frontend can access it. + * Authorization is handled by passing up an apiKey that can be generated + * on the dashboard or via CLI via: + * ``` + * npx convex run --component agent apiKeys:issue + * ``` + */ +export const { + listAgents, + listUsers, + listThreads, + listMessages, + createThread, + generateText, + fetchPromptContext, +} = definePlaygroundAPI(components.agent, { + agents: [weatherAgent, fashionAgent], +}); diff --git a/package.json b/package.json index 2101bfa..19ed031 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,13 @@ "default": "./dist/commonjs/client/index.js" } }, + "./playground": { + "import": { + "@convex-dev/component-source": "./src/client/playground.ts", + "types": "./dist/esm/client/playground.d.ts", + "default": "./dist/esm/client/playground.js" + } + }, "./validators": { "import": { "@convex-dev/component-source": "./src/validators.ts", diff --git a/playground-chef/.gitignore b/playground-chef/.gitignore new file mode 100644 index 0000000..fe60c1c --- /dev/null +++ b/playground-chef/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Ignored for the template, you probably want to remove it: +package-lock.json \ No newline at end of file diff --git a/playground-chef/README.md b/playground-chef/README.md new file mode 100644 index 0000000..8b3ed29 --- /dev/null +++ b/playground-chef/README.md @@ -0,0 +1,28 @@ +# Chat Admin Interface Implementation + +This is a project built with [Chef](https://chef.convex.dev) using [Convex](https://convex.dev) as its backend. + +This project is connected to the Convex deployment named [`pastel-curlew-479`](https://dashboard.convex.dev/d/pastel-curlew-479). + +## Project structure + +The frontend code is in the `app` directory and is built with [Vite](https://vitejs.dev/). + +The backend code is in the `convex` directory. + +`npm run dev` will start the frontend and backend servers. + +## App authentication + +Chef apps use [Convex Auth](https://auth.convex.dev/) with Anonymous auth for easy sign in. You may wish to change this before deploying your app. + +## Developing and deploying your app + +Check out the [Convex docs](https://docs.convex.dev/) for more information on how to develop with Convex. +* If you're new to Convex, the [Overview](https://docs.convex.dev/understanding/) is a good place to start +* Check out the [Hosting and Deployment](https://docs.convex.dev/production/) docs for how to deploy your app +* Read the [Best Practices](https://docs.convex.dev/understanding/best-practices/) guide for tips on how to improve you app further + +## HTTP API + +User-defined http routes are defined in the `convex/router.ts` file. We split these routes into a separate file from `convex/http.ts` to allow us to prevent the LLM from modifying the authentication routes. diff --git a/playground-chef/components.json b/playground-chef/components.json new file mode 100644 index 0000000..6d3998d --- /dev/null +++ b/playground-chef/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/playground-chef/eslint.config.js b/playground-chef/eslint.config.js new file mode 100644 index 0000000..dc2e219 --- /dev/null +++ b/playground-chef/eslint.config.js @@ -0,0 +1,77 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "dist", + "eslint.config.js", + "convex/_generated", + "postcss.config.js", + "tailwind.config.js", + "vite.config.ts", + ], + }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + project: [ + "./tsconfig.node.json", + "./tsconfig.app.json", + "./convex/tsconfig.json", + ], + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // All of these overrides ease getting into + // TypeScript, and can be removed for stricter + // linting down the line. + + // Only warn on unused variables, and ignore variables starting with `_` + "@typescript-eslint/no-unused-vars": [ + "warn", + { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, + ], + + // Allow escaping the compiler + "@typescript-eslint/ban-ts-comment": "error", + + // Allow explicit `any`s + "@typescript-eslint/no-explicit-any": "off", + + // START: Allow implicit `any`s + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + // END: Allow implicit `any`s + + // Allow async functions without await + // for consistency (esp. Convex `handler`s) + "@typescript-eslint/require-await": "off", + }, + }, +); diff --git a/playground-chef/index.html b/playground-chef/index.html new file mode 100644 index 0000000..f587b5c --- /dev/null +++ b/playground-chef/index.html @@ -0,0 +1,16 @@ + + + + + + + + Chef + + + + +
+ + + diff --git a/playground-chef/package.json b/playground-chef/package.json new file mode 100644 index 0000000..60192c2 --- /dev/null +++ b/playground-chef/package.json @@ -0,0 +1,46 @@ +{ + "name": "flex-template", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --open", + "lint": "tsc -p . -noEmit --pretty false && vite build" + }, + "dependencies": { + "@convex-dev/agent": "../", + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-dropdown-menu": "^2.1.14", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-select": "^2.2.4", + "@radix-ui/react-slot": "^1.2.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "lucide-react": "^0.508.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "sonner": "^2.0.3", + "tailwind-merge": "^3.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "~10", + "dotenv": "^16.4.7", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "npm-run-all": "^4.1.5", + "postcss": "~8", + "prettier": "^3.5.3", + "tailwindcss": "~3", + "typescript": "~5.7.2", + "typescript-eslint": "^8.24.1", + "vite": "^6.2.0" + } +} diff --git a/playground-chef/postcss.config.cjs b/playground-chef/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/playground-chef/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/playground-chef/src/App.tsx b/playground-chef/src/App.tsx new file mode 100644 index 0000000..5650bfa --- /dev/null +++ b/playground-chef/src/App.tsx @@ -0,0 +1,425 @@ +import { useCallback, useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import Editor from "@monaco-editor/react"; +import { Button } from "./components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./components/ui/select"; +import { + extractText, + type ContextOptions, + type MessageDoc, + type StorageOptions, +} from "@convex-dev/agent"; +import type { PlaygroundAPI } from "@convex-dev/agent/playground"; +import { anyApi } from "convex/server"; +import { useAction, usePaginatedQuery, useQuery } from "convex/react"; +import { assert } from "convex-helpers"; +import { toast } from "sonner"; +import { CoreMessage } from "ai"; +dayjs.extend(relativeTime); + +const DEFAULT_CONTEXT_OPTIONS: ContextOptions = { + recentMessages: 10, + includeToolCalls: false, + searchOtherThreads: false, + searchOptions: { + limit: 10, + textSearch: true, + vectorSearch: true, + messageRange: { before: 0, after: 0 }, + }, +}; + +const DEFAULT_STORAGE_OPTIONS: StorageOptions = { + saveAllInputMessages: false, + saveAnyInputMessages: true, + saveOutputMessages: true, +}; + +if (!import.meta.env.VITE_PLAYGROUND_API_PATH) { + throw new Error("VITE_PLAYGROUND_API_PATH is not set"); +} +const api = (import.meta.env.VITE_PLAYGROUND_API_PATH as string) + .trim() + .split("/") + .reduce((acc, part) => { + return acc[part]; + }, anyApi) as unknown as PlaygroundAPI; + +const apiKey = import.meta.env.VITE_PLAYGROUND_API_KEY!; +assert(apiKey, "VITE_PLAYGROUND_API_KEY is not set"); + +export default function App() { + const [selectedUser, setSelectedUser] = useState(); + const [selectedThread, setSelectedThread] = useState(); + const [selectedAgent, setSelectedAgent] = useState(); + // const [selectedTool, setSelectedTool] = useState(); + const [selectedMessage, setSelectedMessage] = useState(); + const [newMessage, setNewMessage] = useState(""); + const [contextOptions, setContextOptions] = useState( + JSON.stringify(DEFAULT_CONTEXT_OPTIONS, null, 2) + ); + const [storageOptions, setStorageOptions] = useState( + JSON.stringify(DEFAULT_STORAGE_OPTIONS, null, 2) + ); + const [contextMessages, setContextMessages] = useState([]); + const users = usePaginatedQuery( + api.listUsers, + { apiKey }, + { initialNumItems: 20 } + ); + if (users.results.length > 0 && !selectedUser) { + setSelectedUser(users.results[0].id); + } + + const threads = usePaginatedQuery( + api.listThreads, + selectedUser ? { apiKey, userId: selectedUser } : "skip", + { initialNumItems: 20 } + ); + if (threads.results.length > 0 && !selectedThread) { + setSelectedThread(threads.results[0]._id); + } + + const messages = usePaginatedQuery( + api.listMessages, + selectedThread ? { apiKey, threadId: selectedThread } : "skip", + { initialNumItems: 20 } + ); + if (messages.results.length > 0 && !selectedMessage) { + setSelectedMessage(messages.results[0]); + } + + const agents = useQuery(api.listAgents, { apiKey }); + + const fetchContext = useAction(api.fetchPromptContext); + + const fetchContextMessages = useCallback(async () => { + if (!selectedMessage) { + toast.error("No message selected"); + return; + } + if (!selectedAgent) { + toast.error("No agent selected"); + return; + } + const context = await fetchContext({ + apiKey, + agentName: selectedAgent, + threadId: selectedThread, + userId: selectedUser, + messages: [selectedMessage.message!], + contextOptions: JSON.parse(contextOptions), + beforeMessageId: selectedMessage?._id, + }); + setContextMessages(context); + }, [ + fetchContext, + messages.results, + selectedMessage?._id, + selectedThread, + contextOptions, + ]); + + return ( +
+ {/* Left Panel */} +
+ + +
+ {threads.results.map((thread) => ( +
setSelectedThread(thread._id)} + > +
{thread.title}
+
{thread.summary}
+
+ {thread.latestMessage} +
+
+ Created {dayjs(thread._creationTime).fromNow()} + + Last message{" "} + {dayjs( + thread.lastMessageAt ?? thread._creationTime + ).fromNow()} + +
+
+ ))} +
+ + {threads.status === "CanLoadMore" && ( + + )} +
+ + {/* Middle Panel */} +
+
+ {messages.results.map((message) => ( +
setSelectedMessage(message)} + > +
+
+ + {message.message?.role === "user" + ? "👤" + : message.message?.role === "assistant" + ? "🤖" + message.agentName + : "⚙️"}{" "} + + {message.message?.role === "assistant" && message.tool && ( + + 🧰 Tool Call + + )} + {message.message?.role === "tool" && ( + + 📦 Tool Response + + )} +
+ + {message.usage?.totalTokens} + +
+ +
{message.text}
+ + {message.message && + message.message?.role === "assistant" && + message.tool && + typeof message.message.content !== "string" && ( +
+
+
+ Tool:{" "} + { + message.message.content.find( + (c) => c.type === "tool-call" + )?.toolName + } +
+
+ {JSON.stringify( + message.message.content.find( + (c) => c.type === "tool-call" + )?.args, + null, + 2 + )} +
+
+
+ )} + + {message.message && + message.message?.role === "tool" && + typeof message.message.content !== "string" && ( +
+
+
+ Response from:{" "} + { + message.message.content.find( + (c) => c.type === "tool-result" + )?.toolName + } +
+
+ {JSON.stringify( + message.message.content.find( + (c) => c.type === "tool-result" + )?.result, + null, + 2 + )} +
+
+
+ )} +
+ ))} +
+
+ + {/* Right Panel */} +
+ {/* Selected Message JSON */} +
+

Selected Message

+
+ +
+
+ + {/* New Message Section */} +
+

New Message

+ +
+ + + {/* */} + +
+ + Context Options + +
+ setContextOptions(value || "")} + options={{ + minimap: { enabled: false }, + fontSize: 12, + }} + /> +
+
+ +
+ + Storage Options + +
+ setStorageOptions(value || "")} + options={{ + minimap: { enabled: false }, + fontSize: 12, + }} + /> +
+
+ +