├── src ├── vite-env.d.ts ├── aiChat │ ├── styles.css │ ├── TrashIcon.tsx │ ├── CloseIcon.tsx │ ├── SendIcon.tsx │ ├── SizeIcon.tsx │ ├── InfoCircled.tsx │ └── index.tsx ├── main.tsx ├── components │ ├── typography │ │ └── link.tsx │ └── ui │ │ └── button.tsx ├── lib │ └── utils.tsx ├── App.tsx └── index.css ├── screenshot.png ├── postcss.config.js ├── .gitignore ├── tsconfig.node.json ├── vite.config.ts ├── components.json ├── index.html ├── convex ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── tsconfig.json ├── schema.ts ├── helpers.ts ├── messages.ts ├── ingest │ ├── embed.ts │ └── load.ts └── serve.ts ├── tsconfig.json ├── .eslintrc.cjs ├── tailwind.config.js ├── package.json ├── README.md └── LICENSE /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/get-convex/convex-ai-chat/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/aiChat/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !**/glob-import/dir/node_modules 2 | .DS_Store 3 | .idea 4 | *.cpuprofile 5 | *.local 6 | *.log 7 | /.vscode/ 8 | /docs/.vitepress/cache 9 | dist 10 | dist-ssr 11 | explorations 12 | node_modules 13 | playground-temp 14 | temp 15 | TODOs.md 16 | .eslintcache 17 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/components/typography/link.tsx: -------------------------------------------------------------------------------- 1 | import { se } from "@/lib/utils"; 2 | import { AnchorHTMLAttributes } from "react"; 3 | 4 | export const Link = se< 5 | HTMLAnchorElement, 6 | AnchorHTMLAttributes 7 | >( 8 | "a", 9 | "font-medium text-primary underline underline-offset-4 hover:no-underline" 10 | ); 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import react from "@vitejs/plugin-react"; 3 | import { defineConfig } from "vite"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Convex + React (Vite) 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.5.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./src/*"] 26 | } 27 | }, 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /src/aiChat/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | export function TrashIcon({ className }: { className?: string }) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | 11 | /* These compiler options are required by Convex */ 12 | "target": "ESNext", 13 | "lib": ["ES2021", "dom"], 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "isolatedModules": true, 19 | "skipLibCheck": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | messages: defineTable({ 6 | isViewer: v.boolean(), 7 | sessionId: v.string(), 8 | text: v.string(), 9 | }).index("bySessionId", ["sessionId"]), 10 | documents: defineTable({ 11 | url: v.string(), 12 | text: v.string(), 13 | }).index("byUrl", ["url"]), 14 | chunks: defineTable({ 15 | documentId: v.id("documents"), 16 | text: v.string(), 17 | embeddingId: v.union(v.id("embeddings"), v.null()), 18 | }) 19 | .index("byDocumentId", ["documentId"]) 20 | .index("byEmbeddingId", ["embeddingId"]), 21 | embeddings: defineTable({ 22 | embedding: v.array(v.number()), 23 | chunkId: v.id("chunks"), 24 | }) 25 | .index("byChunkId", ["chunkId"]) 26 | .vectorIndex("byEmbedding", { 27 | vectorField: "embedding", 28 | dimensions: 1536, 29 | }), 30 | }); 31 | -------------------------------------------------------------------------------- /src/aiChat/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | export function CloseIcon({ className }: { className?: string }) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/aiChat/SendIcon.tsx: -------------------------------------------------------------------------------- 1 | export function SendIcon({ className }: { className?: string }) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { ForwardRefRenderFunction, forwardRef } from "react"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | // forward refs 10 | export function fr>( 11 | component: ForwardRefRenderFunction 12 | ) { 13 | const wrapped = forwardRef(component); 14 | wrapped.displayName = component.name; 15 | return wrapped; 16 | } 17 | 18 | // styled element 19 | export function se< 20 | T = HTMLElement, 21 | P extends React.HTMLAttributes = React.HTMLAttributes 22 | >(Tag: keyof React.ReactHTML, ...classNames: ClassValue[]) { 23 | const component = fr(({ className, ...props }, ref) => ( 24 | // @ts-expect-error Too complicated for TypeScript 25 | 26 | )); 27 | component.displayName = Tag[0].toUpperCase() + Tag.slice(1); 28 | return component; 29 | } 30 | -------------------------------------------------------------------------------- /src/aiChat/SizeIcon.tsx: -------------------------------------------------------------------------------- 1 | export function SizeIcon({ className }: { className?: string }) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { ConvexAiChat } from "@/aiChat"; 2 | import { Link } from "@/components/typography/link"; 3 | import { Button } from "@/components/ui/button"; 4 | 5 | function App() { 6 | return ( 7 |
8 |

9 | AI Chat with Convex Vector Search 10 |

11 |

Click the button to open the chat window

12 |

13 | ( 19 | 20 | )} 21 | /> 22 |

23 |

24 | Check out{" "} 25 | 26 | Convex docs 27 | 28 |

29 |
30 | ); 31 | } 32 | 33 | export default App; 34 | -------------------------------------------------------------------------------- /convex/helpers.ts: -------------------------------------------------------------------------------- 1 | import { PaginationResult } from "convex/server"; 2 | import { internal } from "./_generated/api"; 3 | import { Doc, TableNames } from "./_generated/dataModel"; 4 | import { ActionCtx, QueryCtx, internalQuery } from "./_generated/server"; 5 | 6 | export async function paginate( 7 | ctx: ActionCtx, 8 | table: T, 9 | batchSize: number, 10 | callback: (documents: Doc[]) => Promise 11 | ): Promise { 12 | let isDone = false; 13 | let cursor = null; 14 | while (!isDone) { 15 | const result: PaginationResult> = (await ctx.runQuery( 16 | internal.helpers.paginateQuery, 17 | { 18 | table, 19 | cursor, 20 | numItems: batchSize, 21 | } 22 | )) as any; 23 | await callback(result.page); 24 | ({ isDone, continueCursor: cursor } = result); 25 | } 26 | } 27 | 28 | export const paginateQuery = internalQuery( 29 | async ( 30 | ctx: QueryCtx, 31 | args: { table: T; cursor: any; numItems: number } 32 | ) => { 33 | return await ctx.db 34 | .query(args.table) 35 | .paginate({ cursor: args.cursor, numItems: args.numItems }); 36 | } 37 | ); 38 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.5.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as helpers from "../helpers"; 18 | import type * as ingest_embed from "../ingest/embed"; 19 | import type * as ingest_load from "../ingest/load"; 20 | import type * as messages from "../messages"; 21 | import type * as serve from "../serve"; 22 | 23 | /** 24 | * A utility for referencing Convex functions in your app's API. 25 | * 26 | * Usage: 27 | * ```js 28 | * const myFunctionReference = api.myModule.myFunction; 29 | * ``` 30 | */ 31 | declare const fullApi: ApiFromModules<{ 32 | helpers: typeof helpers; 33 | "ingest/embed": typeof ingest_embed; 34 | "ingest/load": typeof ingest_load; 35 | messages: typeof messages; 36 | serve: typeof serve; 37 | }>; 38 | export declare const api: FilterApi< 39 | typeof fullApi, 40 | FunctionReference 41 | >; 42 | export declare const internal: FilterApi< 43 | typeof fullApi, 44 | FunctionReference 45 | >; 46 | -------------------------------------------------------------------------------- /src/aiChat/InfoCircled.tsx: -------------------------------------------------------------------------------- 1 | export function InfoCircled({ className }: { className?: string }) { 2 | return ( 3 | 11 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /convex/messages.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation } from "./_generated/server"; 3 | import { query } from "./_generated/server"; 4 | import { internal } from "./_generated/api"; 5 | 6 | export const list = query({ 7 | args: { 8 | sessionId: v.string(), 9 | }, 10 | handler: async (ctx, args) => { 11 | return await ctx.db 12 | .query("messages") 13 | .withIndex("bySessionId", (q) => q.eq("sessionId", args.sessionId)) 14 | .collect(); 15 | }, 16 | }); 17 | 18 | export const send = mutation({ 19 | args: { 20 | message: v.string(), 21 | sessionId: v.string(), 22 | }, 23 | handler: async (ctx, { message, sessionId }) => { 24 | await ctx.db.insert("messages", { 25 | isViewer: true, 26 | text: message, 27 | sessionId, 28 | }); 29 | await ctx.scheduler.runAfter(0, internal.serve.answer, { 30 | sessionId, 31 | }); 32 | }, 33 | }); 34 | 35 | export const clear = mutation({ 36 | args: { 37 | sessionId: v.string(), 38 | }, 39 | handler: async (ctx, args) => { 40 | const messages = await ctx.db 41 | .query("messages") 42 | .withIndex("bySessionId", (q) => q.eq("sessionId", args.sessionId)) 43 | .collect(); 44 | await Promise.all(messages.map((message) => ctx.db.delete(message._id))); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true, node: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended-type-checked", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: [ 10 | "dist", 11 | "convex/_generated", 12 | ".eslintrc.cjs", 13 | "tailwind.config.js", 14 | // There are currently ESLint errors in shadcn/ui 15 | "src/components/ui", 16 | ], 17 | parser: "@typescript-eslint/parser", 18 | parserOptions: { 19 | project: true, 20 | tsconfigRootDir: __dirname, 21 | }, 22 | plugins: ["react-refresh"], 23 | rules: { 24 | "react-refresh/only-export-components": [ 25 | "warn", 26 | { allowConstantExport: true }, 27 | ], 28 | 29 | // All of these overrides ease getting into 30 | // TypeScript, and can be removed for stricter 31 | // linting down the line. 32 | 33 | // Allow escaping the compiler 34 | "@typescript-eslint/ban-ts-comment": "error", 35 | 36 | // Allow explicit `any`s 37 | "@typescript-eslint/no-explicit-any": "off", 38 | 39 | // START: Allow implicit `any`s 40 | "@typescript-eslint/no-unsafe-argument": "off", 41 | "@typescript-eslint/no-unsafe-assignment": "off", 42 | "@typescript-eslint/no-unsafe-call": "off", 43 | "@typescript-eslint/no-unsafe-member-access": "off", 44 | "@typescript-eslint/no-unsafe-return": "off", 45 | // END: Allow implicit `any`s 46 | 47 | // Allow async functions without await 48 | // for consistency (esp. Convex `handler`s) 49 | "@typescript-eslint/require-await": "off", 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | @media (prefers-color-scheme: dark) { 39 | :root { 40 | --background: 240 10% 3.9%; 41 | --foreground: 0 0% 98%; 42 | 43 | --card: 240 10% 3.9%; 44 | --card-foreground: 0 0% 98%; 45 | 46 | --popover: 240 10% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | 49 | --primary: 0 0% 98%; 50 | --primary-foreground: 240 5.9% 10%; 51 | 52 | --secondary: 240 3.7% 15.9%; 53 | --secondary-foreground: 0 0% 98%; 54 | 55 | --muted: 240 3.7% 15.9%; 56 | --muted-foreground: 240 5% 64.9%; 57 | 58 | --accent: 240 3.7% 15.9%; 59 | --accent-foreground: 0 0% 98%; 60 | 61 | --destructive: 0 62.8% 30.6%; 62 | --destructive-foreground: 0 0% 98%; 63 | 64 | --border: 240 3.7% 15.9%; 65 | --input: 240 3.7% 15.9%; 66 | --ring: 240 4.9% 83.9%; 67 | } 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.5.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { DataModelFromSchemaDefinition } from "convex/server"; 13 | import type { DocumentByName, TableNamesInDataModel } from "convex/server"; 14 | import type { GenericId } from "convex/values"; 15 | import schema from "../schema"; 16 | 17 | /** 18 | * The names of all of your Convex tables. 19 | */ 20 | export type TableNames = TableNamesInDataModel; 21 | 22 | /** 23 | * The type of a document stored in Convex. 24 | * 25 | * @typeParam TableName - A string literal type of the table name (like "users"). 26 | */ 27 | export type Doc = DocumentByName< 28 | DataModel, 29 | TableName 30 | >; 31 | 32 | /** 33 | * An identifier for a document in Convex. 34 | * 35 | * Convex documents are uniquely identified by their `Id`, which is accessible 36 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 37 | * 38 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 39 | * 40 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 41 | * strings when type checking. 42 | * 43 | * @typeParam TableName - A string literal type of the table name (like "users"). 44 | */ 45 | export type Id = GenericId; 46 | 47 | /** 48 | * A type describing your Convex data model. 49 | * 50 | * This type includes information about what tables you have, the type of 51 | * documents stored in those tables, and the indexes defined on them. 52 | * 53 | * This type is used to parameterize methods like `queryGeneric` and 54 | * `mutationGeneric` to make them type-safe. 55 | */ 56 | export type DataModel = DataModelFromSchemaDefinition; 57 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: "2rem", 9 | screens: { 10 | "2xl": "1400px", 11 | }, 12 | }, 13 | extend: { 14 | colors: { 15 | border: "hsl(var(--border))", 16 | input: "hsl(var(--input))", 17 | ring: "hsl(var(--ring))", 18 | background: "hsl(var(--background))", 19 | foreground: "hsl(var(--foreground))", 20 | primary: { 21 | DEFAULT: "hsl(var(--primary))", 22 | foreground: "hsl(var(--primary-foreground))", 23 | }, 24 | secondary: { 25 | DEFAULT: "hsl(var(--secondary))", 26 | foreground: "hsl(var(--secondary-foreground))", 27 | }, 28 | destructive: { 29 | DEFAULT: "hsl(var(--destructive))", 30 | foreground: "hsl(var(--destructive-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | popover: { 41 | DEFAULT: "hsl(var(--popover))", 42 | foreground: "hsl(var(--popover-foreground))", 43 | }, 44 | card: { 45 | DEFAULT: "hsl(var(--card))", 46 | foreground: "hsl(var(--card-foreground))", 47 | }, 48 | }, 49 | borderRadius: { 50 | lg: "var(--radius)", 51 | md: "calc(var(--radius) - 2px)", 52 | sm: "calc(var(--radius) - 4px)", 53 | }, 54 | keyframes: { 55 | "accordion-down": { 56 | from: { height: 0 }, 57 | to: { height: "var(--radix-accordion-content-height)" }, 58 | }, 59 | "accordion-up": { 60 | from: { height: "var(--radix-accordion-content-height)" }, 61 | to: { height: 0 }, 62 | }, 63 | }, 64 | animation: { 65 | "accordion-down": "accordion-down 0.2s ease-out", 66 | "accordion-up": "accordion-up 0.2s ease-out", 67 | }, 68 | }, 69 | }, 70 | plugins: [require("tailwindcss-animate")], 71 | }; 72 | -------------------------------------------------------------------------------- /convex/ingest/embed.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { map } from "modern-async"; 3 | import OpenAI from "openai"; 4 | import { internal } from "../_generated/api"; 5 | import { Id } from "../_generated/dataModel"; 6 | import { 7 | internalAction, 8 | internalMutation, 9 | internalQuery, 10 | } from "../_generated/server"; 11 | import { paginate } from "../helpers"; 12 | 13 | export const embedAll = internalAction({ 14 | args: {}, 15 | handler: async (ctx) => { 16 | await paginate(ctx, "documents", 20, async (documents) => { 17 | await ctx.runAction(internal.ingest.embed.embedList, { 18 | documentIds: documents.map((doc) => doc._id), 19 | }); 20 | }); 21 | }, 22 | }); 23 | 24 | export const embedList = internalAction({ 25 | args: { 26 | documentIds: v.array(v.id("documents")), 27 | }, 28 | handler: async (ctx, { documentIds }) => { 29 | const chunks = ( 30 | await map(documentIds, (documentId) => 31 | ctx.runQuery(internal.ingest.embed.chunksNeedingEmbedding, { 32 | documentId, 33 | }) 34 | ) 35 | ).flat(); 36 | 37 | const embeddings = await embedTexts(chunks.map((chunk) => chunk.text)); 38 | await map(embeddings, async (embedding, i) => { 39 | const { _id: chunkId } = chunks[i]; 40 | await ctx.runMutation(internal.ingest.embed.addEmbedding, { 41 | chunkId, 42 | embedding, 43 | }); 44 | }); 45 | }, 46 | }); 47 | 48 | export const chunksNeedingEmbedding = internalQuery( 49 | async (ctx, { documentId }: { documentId: Id<"documents"> }) => { 50 | const chunks = await ctx.db 51 | .query("chunks") 52 | .withIndex("byDocumentId", (q) => q.eq("documentId", documentId)) 53 | .collect(); 54 | return chunks.filter((chunk) => chunk.embeddingId === null); 55 | } 56 | ); 57 | 58 | export const addEmbedding = internalMutation( 59 | async ( 60 | ctx, 61 | { chunkId, embedding }: { chunkId: Id<"chunks">; embedding: number[] } 62 | ) => { 63 | const embeddingId = await ctx.db.insert("embeddings", { 64 | embedding, 65 | chunkId, 66 | }); 67 | await ctx.db.patch(chunkId, { embeddingId }); 68 | } 69 | ); 70 | 71 | export async function embedTexts(texts: string[]) { 72 | if (texts.length === 0) return []; 73 | const openai = new OpenAI(); 74 | const { data } = await openai.embeddings.create({ 75 | input: texts, 76 | model: "text-embedding-ada-002", 77 | }); 78 | return data.map(({ embedding }) => embedding); 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "convex-ai-chat", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm-run-all dev:init --parallel dev:frontend dev:backend", 8 | "dev:frontend": "vite --open", 9 | "dev:backend": "convex dev", 10 | "dev:init": "convex dev --until-success && convex dashboard", 11 | "build": "tsc && vite build", 12 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 13 | "preview": "vite preview" 14 | }, 15 | "dependencies": { 16 | "@hookform/resolvers": "^3.3.2", 17 | "@radix-ui/react-accordion": "^1.1.2", 18 | "@radix-ui/react-alert-dialog": "^1.0.5", 19 | "@radix-ui/react-aspect-ratio": "^1.0.3", 20 | "@radix-ui/react-avatar": "^1.0.4", 21 | "@radix-ui/react-checkbox": "^1.0.4", 22 | "@radix-ui/react-collapsible": "^1.0.3", 23 | "@radix-ui/react-context-menu": "^2.1.5", 24 | "@radix-ui/react-dialog": "^1.0.5", 25 | "@radix-ui/react-dropdown-menu": "^2.0.6", 26 | "@radix-ui/react-hover-card": "^1.0.7", 27 | "@radix-ui/react-icons": "^1.3.0", 28 | "@radix-ui/react-label": "^2.0.2", 29 | "@radix-ui/react-menubar": "^1.0.4", 30 | "@radix-ui/react-navigation-menu": "^1.1.4", 31 | "@radix-ui/react-popover": "^1.0.7", 32 | "@radix-ui/react-progress": "^1.0.3", 33 | "@radix-ui/react-radio-group": "^1.1.3", 34 | "@radix-ui/react-scroll-area": "^1.0.5", 35 | "@radix-ui/react-select": "^2.0.0", 36 | "@radix-ui/react-separator": "^1.0.3", 37 | "@radix-ui/react-slider": "^1.1.2", 38 | "@radix-ui/react-slot": "^1.0.2", 39 | "@radix-ui/react-switch": "^1.0.3", 40 | "@radix-ui/react-tabs": "^1.0.4", 41 | "@radix-ui/react-toast": "^1.1.5", 42 | "@radix-ui/react-toggle": "^1.0.3", 43 | "@radix-ui/react-tooltip": "^1.0.7", 44 | "cheerio": "^1.0.0-rc.12", 45 | "class-variance-authority": "^0.7.0", 46 | "clsx": "^2.0.0", 47 | "cmdk": "^0.2.0", 48 | "convex": "^1.5.1", 49 | "date-fns": "^2.30.0", 50 | "langchain": "^0.0.189", 51 | "modern-async": "^1.1.4", 52 | "openai": "^4.19.0", 53 | "react": "^18.2.0", 54 | "react-day-picker": "^8.9.1", 55 | "react-dom": "^18.2.0", 56 | "react-hook-form": "^7.47.0", 57 | "tailwind-merge": "^1.14.0", 58 | "tailwindcss-animate": "^1.0.7", 59 | "zod": "^3.22.4" 60 | }, 61 | "devDependencies": { 62 | "@types/node": "^20.7.0", 63 | "@types/react": "^18.2.21", 64 | "@types/react-dom": "^18.2.7", 65 | "@typescript-eslint/eslint-plugin": "^6.7.0", 66 | "@typescript-eslint/parser": "^6.7.0", 67 | "@vitejs/plugin-react": "^4.0.4", 68 | "autoprefixer": "^10.4.16", 69 | "eslint": "^8.49.0", 70 | "eslint-plugin-react-hooks": "^4.6.0", 71 | "eslint-plugin-react-refresh": "^0.4.3", 72 | "npm-run-all": "^4.1.5", 73 | "postcss": "^8.4.30", 74 | "tailwindcss": "^3.3.3", 75 | "typescript": "^5.2.2", 76 | "vite": "^4.4.9" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.5.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /convex/serve.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { map } from "modern-async"; 3 | import OpenAI from "openai"; 4 | import { ChatCompletionMessageParam } from "openai/resources/index"; 5 | import { 6 | internalAction, 7 | internalMutation, 8 | internalQuery, 9 | } from "./_generated/server"; 10 | import { embedTexts } from "./ingest/embed"; 11 | import { internal } from "./_generated/api"; 12 | import { Id } from "./_generated/dataModel"; 13 | 14 | const OPENAI_MODEL = "gpt-3.5-turbo"; 15 | 16 | export const answer = internalAction({ 17 | args: { 18 | sessionId: v.string(), 19 | }, 20 | handler: async (ctx, { sessionId }) => { 21 | const messages = await ctx.runQuery(internal.serve.getMessages, { 22 | sessionId, 23 | }); 24 | const lastUserMessage = messages.at(-1)!.text; 25 | 26 | const [embedding] = await embedTexts([lastUserMessage]); 27 | 28 | const searchResults = await ctx.vectorSearch("embeddings", "byEmbedding", { 29 | vector: embedding, 30 | limit: 8, 31 | }); 32 | 33 | const relevantDocuments = await ctx.runQuery(internal.serve.getChunks, { 34 | embeddingIds: searchResults.map(({ _id }) => _id), 35 | }); 36 | 37 | const messageId = await ctx.runMutation(internal.serve.addBotMessage, { 38 | sessionId, 39 | }); 40 | 41 | try { 42 | const openai = new OpenAI(); 43 | const stream = await openai.chat.completions.create({ 44 | model: OPENAI_MODEL, 45 | stream: true, 46 | messages: [ 47 | { 48 | role: "system", 49 | content: 50 | "Answer the user question based on the provided documents " + 51 | "or report that the question cannot be answered based on " + 52 | "these documents. Keep the answer informative but brief, " + 53 | "do not enumerate all possibilities.", 54 | }, 55 | ...(relevantDocuments.map(({ text }) => ({ 56 | role: "system", 57 | content: "Relevant document:\n\n" + text, 58 | })) as ChatCompletionMessageParam[]), 59 | ...(messages.map(({ isViewer, text }) => ({ 60 | role: isViewer ? "user" : "assistant", 61 | content: text, 62 | })) as ChatCompletionMessageParam[]), 63 | ], 64 | }); 65 | let text = ""; 66 | for await (const { choices } of stream) { 67 | const replyDelta = choices[0].delta.content; 68 | if (typeof replyDelta === "string" && replyDelta.length > 0) { 69 | text += replyDelta; 70 | await ctx.runMutation(internal.serve.updateBotMessage, { 71 | messageId, 72 | text, 73 | }); 74 | } 75 | } 76 | } catch (error: any) { 77 | await ctx.runMutation(internal.serve.updateBotMessage, { 78 | messageId, 79 | text: "I cannot reply at this time. Reach out to the team on Discord", 80 | }); 81 | throw error; 82 | } 83 | }, 84 | }); 85 | 86 | export const getMessages = internalQuery( 87 | async (ctx, { sessionId }: { sessionId: string }) => { 88 | return await ctx.db 89 | .query("messages") 90 | .withIndex("bySessionId", (q) => q.eq("sessionId", sessionId)) 91 | .collect(); 92 | } 93 | ); 94 | 95 | export const getChunks = internalQuery( 96 | async (ctx, { embeddingIds }: { embeddingIds: Id<"embeddings">[] }) => { 97 | return await map( 98 | embeddingIds, 99 | async (embeddingId) => 100 | (await ctx.db 101 | .query("chunks") 102 | .withIndex("byEmbeddingId", (q) => q.eq("embeddingId", embeddingId)) 103 | .unique())! 104 | ); 105 | } 106 | ); 107 | 108 | export const addBotMessage = internalMutation( 109 | async (ctx, { sessionId }: { sessionId: string }) => { 110 | return await ctx.db.insert("messages", { 111 | isViewer: false, 112 | text: "", 113 | sessionId, 114 | }); 115 | } 116 | ); 117 | 118 | export const updateBotMessage = internalMutation( 119 | async ( 120 | ctx, 121 | { messageId, text }: { messageId: Id<"messages">; text: string } 122 | ) => { 123 | await ctx.db.patch(messageId, { text }); 124 | } 125 | ); 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Chat with Convex Vector Search 2 | 3 | An example of building AI-powered chat interface using Convex 4 | [vector search](https://docs.convex.dev/vector-search). 5 | 6 | ![Screenshot of a website with AI chat modal open](./screenshot.png "AI chat UI") 7 | 8 | ## Overview: 9 | 10 | This app demonstrates how you can add a chat bot to an existing website, powered 11 | by Convex. 12 | 13 | - The chat is trigged by a button in [`App.tsx`](./src/App.tsx) 14 | - The chat frontend is all in [`src/aiChat`](./src/aiChat/index.tsx) 15 | - An example of web scraping is in 16 | [`convex/ingest/load.ts`](./convex/ingest/load.ts) 17 | - Embedding is performed in [`convex/ingest/embed.ts`](./convex/ingest/embed.ts) 18 | - The public endpoints for the backend are in 19 | [`convex/messages.ts`](./convex/messages.ts) 20 | - The answering logic is in [`convex/serve.ts`](./convex/serve.ts) 21 | 22 | ## Running the App 23 | 24 | ``` 25 | npm install 26 | npm run dev 27 | ``` 28 | 29 | This will configure a Convex project if you don't already have one, open the 30 | Convex dashboard and the web app running on `localhost`. 31 | 32 | For the chat itself to work, you must configure the following environment 33 | variable on the Convex dashboard: 34 | 35 | - `OPENAI_API_KEY` set to an [OpenAI](https://platform.openai.com/) API key 36 | (should start with `sk-`) 37 | 38 | You can change the LLM identifier `OPENAI_MODEL` in 39 | [`convex/serve.ts`](./convex/serve.ts) to `"gpt-4-32k"` if you're paying for 40 | OpenAI to improve the quality of responses. 41 | 42 | # What is Convex? 43 | 44 | [Convex](https://convex.dev) is a hosted backend platform with a built-in 45 | database that lets you write your 46 | [database schema](https://docs.convex.dev/database/schemas) and 47 | [server functions](https://docs.convex.dev/functions) in 48 | [TypeScript](https://docs.convex.dev/typescript). Server-side database 49 | [queries](https://docs.convex.dev/functions/query-functions) automatically 50 | [cache](https://docs.convex.dev/functions/query-functions#caching--reactivity) 51 | and [subscribe](https://docs.convex.dev/client/react#reactivity) to data, 52 | powering a 53 | [realtime `useQuery` hook](https://docs.convex.dev/client/react#fetching-data) 54 | in our [React client](https://docs.convex.dev/client/react). There are also 55 | [Python](https://docs.convex.dev/client/python), 56 | [Rust](https://docs.convex.dev/client/rust), 57 | [ReactNative](https://docs.convex.dev/client/react-native), and 58 | [Node](https://docs.convex.dev/client/javascript) clients, as well as a 59 | straightforward 60 | [HTTP API](https://github.com/get-convex/convex-js/blob/main/src/browser/http_client.ts#L40). 61 | 62 | The database support 63 | [NoSQL-style documents](https://docs.convex.dev/database/document-storage) with 64 | [relationships](https://docs.convex.dev/database/document-ids) and 65 | [custom indexes](https://docs.convex.dev/database/indexes/) (including on fields 66 | in nested objects). 67 | 68 | The [`query`](https://docs.convex.dev/functions/query-functions) and 69 | [`mutation`](https://docs.convex.dev/functions/mutation-functions) server 70 | functions have transactional, low latency access to the database and leverage 71 | our [`v8` runtime](https://docs.convex.dev/functions/runtimes) with 72 | [determinism guardrails](https://docs.convex.dev/functions/runtimes#using-randomness-and-time-in-queries-and-mutations) 73 | to provide the strongest ACID guarantees on the market: immediate consistency, 74 | serializable isolation, and automatic conflict resolution via 75 | [optimistic multi-version concurrency control](https://docs.convex.dev/database/advanced/occ) 76 | (OCC / MVCC). 77 | 78 | The [`action` server functions](https://docs.convex.dev/functions/actions) have 79 | access to external APIs and enable other side-effects and non-determinism in 80 | either our [optimized `v8` runtime](https://docs.convex.dev/functions/runtimes) 81 | or a more 82 | [flexible `node` runtime](https://docs.convex.dev/functions/runtimes#nodejs-runtime). 83 | 84 | Functions can run in the background via 85 | [scheduling](https://docs.convex.dev/scheduling/scheduled-functions) and 86 | [cron jobs](https://docs.convex.dev/scheduling/cron-jobs). 87 | 88 | Development is cloud-first, with 89 | [hot reloads for server function](https://docs.convex.dev/cli#run-the-convex-dev-server) 90 | editing via the [CLI](https://docs.convex.dev/cli). There is a 91 | [dashbord UI](https://docs.convex.dev/dashboard) to 92 | [browse and edit data](https://docs.convex.dev/dashboard/deployments/data), 93 | [edit environment variables](https://docs.convex.dev/production/environment-variables), 94 | [view logs](https://docs.convex.dev/dashboard/deployments/logs), 95 | [run server functions](https://docs.convex.dev/dashboard/deployments/functions), 96 | and more. 97 | 98 | There are built-in features for 99 | [reactive pagination](https://docs.convex.dev/database/pagination), 100 | [file storage](https://docs.convex.dev/file-storage), 101 | [reactive search](https://docs.convex.dev/text-search), 102 | [https endpoints](https://docs.convex.dev/functions/http-actions) (for 103 | webhooks), 104 | [streaming import/export](https://docs.convex.dev/database/import-export/), and 105 | [runtime data validation](https://docs.convex.dev/database/schemas#validators) 106 | for [function arguments](https://docs.convex.dev/functions/args-validation) and 107 | [database data](https://docs.convex.dev/database/schemas#schema-validation). 108 | 109 | Everything scales automatically, and it’s 110 | [free to start](https://www.convex.dev/plans). 111 | -------------------------------------------------------------------------------- /convex/ingest/load.ts: -------------------------------------------------------------------------------- 1 | import { CheerioAPI, load } from "cheerio"; 2 | import { v } from "convex/values"; 3 | import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; 4 | import { map } from "modern-async"; 5 | import { internal } from "../_generated/api"; 6 | import { internalAction, internalMutation } from "../_generated/server"; 7 | import { Doc } from "../_generated/dataModel"; 8 | 9 | export const scrapeSite = internalAction({ 10 | args: { 11 | sitemapUrl: v.string(), 12 | limit: v.optional(v.number()), 13 | }, 14 | handler: async (ctx, { sitemapUrl, limit }) => { 15 | const response = await fetch(sitemapUrl); 16 | const xml = await response.text(); 17 | const $ = load(xml, { xmlMode: true }); 18 | const urls = $("url > loc") 19 | .map((_i, elem) => $(elem).text()) 20 | .get() 21 | .slice(0, limit); 22 | await map(urls, (url) => 23 | ctx.scheduler.runAfter(0, internal.ingest.load.fetchSingle, { url }) 24 | ); 25 | }, 26 | }); 27 | 28 | export const fetchSingle = internalAction({ 29 | args: { 30 | url: v.string(), 31 | }, 32 | handler: async (ctx, { url }) => { 33 | const response = await fetch(url); 34 | const text = parsePage(await response.text()); 35 | if (text.length > 0) { 36 | await ctx.runMutation(internal.ingest.load.updateDocument, { url, text }); 37 | } 38 | }, 39 | }); 40 | 41 | export const updateDocument = internalMutation( 42 | async (ctx, { url, text }: { url: string; text: string }) => { 43 | const latestVersion = await ctx.db 44 | .query("documents") 45 | .withIndex("byUrl", (q) => q.eq("url", url)) 46 | .order("desc") 47 | .first(); 48 | 49 | const hasChanged = latestVersion === null || latestVersion.text !== text; 50 | if (hasChanged) { 51 | const documentId = await ctx.db.insert("documents", { url, text }); 52 | const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", { 53 | chunkSize: 2000, 54 | chunkOverlap: 100, 55 | }); 56 | const chunks = await splitter.splitText(text); 57 | await map(chunks, async (chunk) => { 58 | await ctx.db.insert("chunks", { 59 | documentId, 60 | text: chunk, 61 | embeddingId: null, 62 | }); 63 | }); 64 | } 65 | } 66 | ); 67 | 68 | export const eraseStaleDocumentsAndChunks = internalMutation({ 69 | args: { 70 | forReal: v.boolean(), 71 | }, 72 | handler: async (ctx, args) => { 73 | const allDocuments = await ctx.db 74 | .query("documents") 75 | .order("desc") 76 | .collect(); 77 | const byUrl: Record[]> = {}; 78 | allDocuments.forEach((doc) => { 79 | byUrl[doc.url] ??= []; 80 | byUrl[doc.url].push(doc); 81 | }); 82 | await map(Object.values(byUrl), async (docs) => { 83 | if (docs.length > 1) { 84 | await map(docs.slice(1), async (doc) => { 85 | const chunks = await ctx.db 86 | .query("chunks") 87 | .withIndex("byDocumentId", (q) => q.eq("documentId", doc._id)) 88 | .collect(); 89 | if (args.forReal) { 90 | await ctx.db.delete(doc._id); 91 | await map(chunks, (chunk) => ctx.db.delete(chunk._id)); 92 | } else { 93 | console.log( 94 | "Would delete", 95 | doc._id, 96 | doc.url, 97 | new Date(doc._creationTime), 98 | "chunk count: " + chunks.length 99 | ); 100 | } 101 | }); 102 | } 103 | }); 104 | }, 105 | }); 106 | 107 | function parsePage(text: string) { 108 | const $ = load(text); 109 | return parse($, $(".markdown")) 110 | .replace(/(?:\n\s+){3,}/g, "\n\n") 111 | .trim(); 112 | } 113 | 114 | function parse($: CheerioAPI, element: any) { 115 | let result = ""; 116 | 117 | $(element) 118 | .contents() 119 | .each((_, el) => { 120 | // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison 121 | if (el.type === "text") { 122 | result += $(el).text().trim() + " "; 123 | return; 124 | } 125 | const tagName = (el as any).tagName; 126 | switch (tagName) { 127 | case "code": 128 | if ($(el).has("span").length > 0) { 129 | result += 130 | "```\n" + 131 | $(el) 132 | .children() 133 | .map((_, line) => $(line).text()) 134 | .get() 135 | .join("\n") + 136 | "\n```\n"; 137 | return; 138 | } 139 | result += " `" + $(el).text() + "` "; 140 | return; 141 | case "a": { 142 | if ($(el).hasClass("hash-link")) { 143 | return; 144 | } 145 | let href = $(el).attr("href")!; 146 | if (href.startsWith("/")) { 147 | href = "https://docs.convex.dev" + href; 148 | } 149 | result += " [" + $(el).text() + "](" + href + ") "; 150 | return; 151 | } 152 | case "strong": 153 | case "em": 154 | result += " " + $(el).text() + " "; 155 | return; 156 | case "h1": 157 | case "h2": 158 | case "h3": 159 | case "h4": 160 | case "h5": 161 | result += "#".repeat(+tagName.slice(1)) + " " + $(el).text() + "\n\n"; 162 | return; 163 | } 164 | result += parse($, el); 165 | result += "\n\n"; 166 | }); 167 | 168 | return result; 169 | } 170 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.5.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /src/aiChat/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ConvexProvider, 3 | ConvexReactClient, 4 | useMutation, 5 | useQuery, 6 | } from "convex/react"; 7 | import { 8 | FormEvent, 9 | ReactNode, 10 | useCallback, 11 | useEffect, 12 | useMemo, 13 | useRef, 14 | useState, 15 | } from "react"; 16 | import { createPortal } from "react-dom"; 17 | import { api } from "../../convex/_generated/api.js"; 18 | import { CloseIcon } from "./CloseIcon.js"; 19 | import { InfoCircled } from "./InfoCircled.js"; 20 | import { SendIcon } from "./SendIcon.js"; 21 | import { SizeIcon } from "./SizeIcon.js"; 22 | import { TrashIcon } from "./TrashIcon.js"; 23 | 24 | export function ConvexAiChat({ 25 | convexUrl, 26 | infoMessage, 27 | name, 28 | welcomeMessage, 29 | renderTrigger, 30 | }: { 31 | convexUrl: string; 32 | name: string; 33 | infoMessage: ReactNode; 34 | welcomeMessage: string; 35 | renderTrigger: (onClick: () => void) => ReactNode; 36 | }) { 37 | const [hasOpened, setHasOpened] = useState(false); 38 | const [dialogOpen, setDialogOpen] = useState(false); 39 | 40 | const handleCloseDialog = useCallback(() => { 41 | setDialogOpen(false); 42 | }, []); 43 | 44 | return ( 45 | <> 46 | {renderTrigger(() => { 47 | setHasOpened(true); 48 | setDialogOpen(!dialogOpen); 49 | })} 50 | {hasOpened 51 | ? createPortal( 52 | , 60 | document.body 61 | ) 62 | : null} 63 | 64 | ); 65 | } 66 | 67 | export function ConvexAiChatDialog({ 68 | convexUrl, 69 | infoMessage, 70 | isOpen, 71 | name, 72 | welcomeMessage, 73 | onClose, 74 | }: { 75 | convexUrl: string; 76 | infoMessage: ReactNode; 77 | isOpen: boolean; 78 | name: string; 79 | welcomeMessage: string; 80 | onClose: () => void; 81 | }) { 82 | const client = useMemo(() => new ConvexReactClient(convexUrl), [convexUrl]); 83 | 84 | return ( 85 | 86 | 93 | 94 | ); 95 | } 96 | 97 | export function Dialog({ 98 | infoMessage, 99 | isOpen, 100 | name, 101 | welcomeMessage, 102 | onClose, 103 | }: { 104 | infoMessage: ReactNode; 105 | isOpen: boolean; 106 | name: string; 107 | welcomeMessage: string; 108 | onClose: () => void; 109 | }) { 110 | const sessionId = useSessionId(); 111 | const remoteMessages = useQuery(api.messages.list, { sessionId }); 112 | const messages = useMemo( 113 | () => 114 | [{ isViewer: false, text: welcomeMessage, _id: "0" }].concat( 115 | (remoteMessages ?? []) as { 116 | isViewer: boolean; 117 | text: string; 118 | _id: string; 119 | }[] 120 | ), 121 | [remoteMessages, welcomeMessage] 122 | ); 123 | const sendMessage = useMutation(api.messages.send); 124 | const clearMesages = useMutation(api.messages.clear); 125 | 126 | const [expanded, setExpanded] = useState(false); 127 | const [isScrolled, setScrolled] = useState(false); 128 | 129 | const [input, setInput] = useState(""); 130 | 131 | const handleExpand = () => { 132 | setExpanded(!expanded); 133 | setScrolled(false); 134 | }; 135 | 136 | const handleSend = async (event: FormEvent) => { 137 | event.preventDefault(); 138 | await sendMessage({ message: input, sessionId }); 139 | setInput(""); 140 | setScrolled(false); 141 | }; 142 | 143 | const handleClearMessages = async () => { 144 | await clearMesages({ sessionId }); 145 | setScrolled(false); 146 | }; 147 | 148 | const listRef = useRef(null); 149 | 150 | useEffect(() => { 151 | if (isScrolled) { 152 | return; 153 | } 154 | // Using `setTimeout` to make sure scrollTo works on button click in Chrome 155 | setTimeout(() => { 156 | listRef.current?.scrollTo({ 157 | top: listRef.current.scrollHeight, 158 | behavior: "smooth", 159 | }); 160 | }, 0); 161 | }, [messages, isScrolled]); 162 | 163 | return ( 164 |
176 |
177 | 191 | 197 | 203 | 209 |
210 |
{ 214 | setScrolled(true); 215 | }} 216 | > 217 | {remoteMessages === undefined ? ( 218 | <> 219 |
220 |
221 | 222 | ) : ( 223 | messages.map((message) => ( 224 |
225 |
231 | {message.isViewer ? <>You : <>{name}} 232 |
233 | {message.text === "" ? ( 234 |
235 | ) : ( 236 |
247 | {message.text} 248 |
249 | )} 250 |
251 | )) 252 | )} 253 |
254 |
void handleSend(event)} 257 | > 258 | setInput(event.target.value)} 265 | /> 266 | 272 |
273 |
274 | ); 275 | } 276 | 277 | const STORE = (typeof window === "undefined" ? null : window)?.sessionStorage; 278 | const STORE_KEY = "ConvexSessionId"; 279 | 280 | function useSessionId() { 281 | const [sessionId] = useState( 282 | () => STORE?.getItem(STORE_KEY) ?? crypto.randomUUID() 283 | ); 284 | 285 | // Get or set the ID from our desired storage location, whenever it changes. 286 | useEffect(() => { 287 | STORE?.setItem(STORE_KEY, sessionId); 288 | }, [sessionId]); 289 | 290 | return sessionId; 291 | } 292 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2024 Convex, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------