diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a7da1d..102effa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,3 +15,24 @@ repos: entry: uvx ty check language: python types_or: [ python, pyi ] + + - id: frontend-format + name: frontend format + entry: bash -c 'cd frontend && bun format' + language: system + files: ^frontend/ + pass_filenames: false + + - id: frontend-lint + name: frontend lint + entry: bash -c 'cd frontend && bun lint' + language: system + files: ^frontend/ + pass_filenames: false + + - id: frontend-check + name: frontend check + entry: bash -c 'cd frontend && bun check' + language: system + files: ^frontend/ + pass_filenames: false diff --git a/CLAUDE.md b/CLAUDE.md index f4b2c63..36410d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,108 +1,253 @@ +This file provides guidance to Claude Code (claude.ai/code) when working with code in +this repository. + +## Project Overview + +AI agent with Telegram bot interface and SvelteKit web frontend, using Convex ( +self-hosted) as the backend database. + +## Architecture + +``` +stealth-ai-relay/ +├── backend/ # Python Telegram bot (aiogram + pydantic-ai) +├── frontend/ # SvelteKit + Svelte 5 + Tailwind CSS 4 + Convex +└── caddy/ # Reverse proxy config +``` + +- **Convex is self-hosted** - all env vars are configured, use `CONVEX_SELF_HOSTED_URL` + for backend +- `/api` path is reserved - use `/service` for internal HTTP endpoints in frontend +- Frontend lives at root, Convex at `/convex`, Convex HTTP at `/convex-http` +- **No code comments** - do not write comments in code, code should be self-documenting + +## Commands + +### Backend (Python) + +```bash +cd backend + +# Type checking +ty check + +# Linting and formatting +ruff format +ruff check --fix +``` + +### Frontend (SvelteKit) + +```bash +cd frontend + +bun check # Type check +bun format # Format with Prettier +bun lint # Lint with ESLint + Prettier +``` + +## MCP Tools + +### Context7 + +Use `mcp__context7__resolve-library-id` then `mcp__context7__query-docs` to fetch +up-to-date documentation for any library (Convex, aiogram, pydantic-ai, etc.). + +### Svelte MCP (REQUIRED for Svelte work) + +1. `mcp__svelte__list-sections` - discover available docs +2. `mcp__svelte__get-documentation` - fetch relevant sections +3. `mcp__svelte__svelte-autofixer` - MUST use before sending any Svelte code +4. `mcp__svelte__playground-link` - generate playground links (ask user first) + +## Svelte 5 + SvelteKit + +### Setup Convex in SvelteKit + +```sveltehtml + + + +{@render children()} +``` + +### Using Convex queries/mutations + +```sveltehtml + + + +{#if tasks.isLoading} + Loading... +{:else if tasks.error} + Error: {tasks.error.toString()} +{:else} + {#each tasks.data as task} +
{task.text}
+ {/each} +{/if} +``` + +## Python Backend + +Uses aiogram 3.x for Telegram bot, pydantic-settings for config, pydantic-ai for AI +features. + +### Logging pattern + +```python +from utils.logging import logger + +logger.info("Test") +``` + +--- + # Svelte -You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: + +You are able to use the Svelte MCP server, where you have access to comprehensive Svelte +5 and SvelteKit documentation. Here's how to use the available tools effectively: ## Available MCP Tools: ### 1. list-sections -Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. -When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. +Use this FIRST to discover all available documentation sections. Returns a structured +list with titles, use_cases, and paths. +When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the +chat to find relevant sections. ### 2. get-documentation -Retrieves full documentation content for specific sections. Accepts single or multiple sections. -After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. +Retrieves full documentation content for specific sections. Accepts single or multiple +sections. +After calling the list-sections tool, you MUST analyze the returned documentation +sections (especially the use_cases field) and then use the get-documentation tool to +fetch ALL documentation sections that are relevant for the user's task. ### 3. svelte-autofixer Analyzes Svelte code and returns issues and suggestions. -You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. +You MUST use this tool whenever writing Svelte code before sending it to the user. Keep +calling it until no issues or suggestions are returned. ### 4. playground-link Generates a Svelte Playground link with the provided code. -After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. - +After completing the code, ask the user if they want a playground link. Only call this +tool after user confirmation and NEVER if code was written to files in their project. # Convex guidelines + ## Function guidelines + ### New function syntax + - ALWAYS use the new function syntax for Convex functions. For example: + ```typescript -import { query } from "./_generated/server"; -import { v } from "convex/values"; +import {query} from "./_generated/server"; +import {v} from "convex/values"; + export const f = query({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { + args: {}, + returns: v.null(), + handler: async (ctx, args) => { // Function body - }, + }, }); ``` ### Http endpoint syntax -- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: + +- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. + For example: + ```typescript -import { httpRouter } from "convex/server"; -import { httpAction } from "./_generated/server"; +import {httpRouter} from "convex/server"; +import {httpAction} from "./_generated/server"; + const http = httpRouter(); http.route({ - path: "/echo", - method: "POST", - handler: httpAction(async (ctx, req) => { + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { const body = await req.bytes(); - return new Response(body, { status: 200 }); - }), + return new Response(body, {status: 200}); + }), }); ``` -- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. + +- HTTP endpoints are always registered at the exact path you specify in the `path` + field. For example, if you specify `/api/someRoute`, the endpoint will be registered + at `/api/someRoute`. ### Validators + - Below is an example of an array validator: + ```typescript -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; +import {mutation} from "./_generated/server"; +import {v} from "convex/values"; export default mutation({ -args: { + args: { simpleArray: v.array(v.union(v.string(), v.number())), -}, -handler: async (ctx, args) => { + }, + handler: async (ctx, args) => { //... -}, + }, }); ``` -- Below is an example of a schema with validators that codify a discriminated union type: + +- Below is an example of a schema with validators that codify a discriminated union + type: + ```typescript -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; +import {defineSchema, defineTable} from "convex/server"; +import {v} from "convex/values"; export default defineSchema({ - results: defineTable( - v.union( - v.object({ - kind: v.literal("error"), - errorMessage: v.string(), - }), - v.object({ - kind: v.literal("success"), - value: v.number(), - }), - ), - ) + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ) }); ``` -- Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value: + +- Always use the `v.null()` validator when returning a null value. Below is an example + query that returns a null value: + ```typescript -import { query } from "./_generated/server"; -import { v } from "convex/values"; +import {query} from "./_generated/server"; +import {v} from "convex/values"; export const exampleQuery = query({ args: {}, returns: v.null(), handler: async (ctx, args) => { - console.log("This query returns a null value"); - return null; + console.log("This query returns a null value"); + return null; }, }); ``` @@ -121,20 +266,40 @@ export const exampleQuery = query({ | 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`. -- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. + +- 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`. +- Use `query`, `mutation`, and `action` to register public functions. These functions + are part of the public API and are exposed to the public Internet. Do NOT use `query`, + `mutation`, or `action` to register sensitive internal functions that should be kept + private. - You CANNOT register a function through the `api` or `internal` objects. -- ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator. -- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. +- ALWAYS include argument and return validators for all Convex functions. This includes + all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and + `internalAction`. If a function doesn't return anything, include `returns: v.null()` + as its output validator. +- If the JavaScript implementation of a Convex function doesn't have a return value, it + implicitly returns `null`. ### Function calling + - Use `ctx.runQuery` to call a query from a query, mutation, or action. - Use `ctx.runMutation` to call a mutation from a mutation or action. - Use `ctx.runAction` to call an action from an action. -- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. -- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. -- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. -- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, +- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to + Node). Otherwise, pull out the shared code into a helper async function and call that + directly instead. +- Try to use as few calls from actions to queries and mutations as possible. Queries and + mutations are transactions, so splitting logic up into multiple calls introduces the + risk of race conditions. +- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee + function directly into one of these calls. +- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in + the same file, specify a type annotation on the return value to work around TypeScript + circularity limitations. For example, + ``` export const f = query({ args: { name: v.string() }, @@ -155,88 +320,132 @@ export const g = query({ ``` ### Function references + - Function references are pointers to registered Convex functions. -- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. -- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. -- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. -- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. -- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. +- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call + public functions registered with `query`, `mutation`, or `action`. +- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to + call internal (or private) functions registered with `internalQuery`, + `internalMutation`, or `internalAction`. +- Convex uses file-based routing, so a public function defined in `convex/example.ts` + named `f` has a function reference of `api.example.f`. +- A private function defined in `convex/example.ts` named `g` has a function reference + of `internal.example.g`. +- Functions can also registered within directories nested within the `convex/` folder. + For example, a public function `h` defined in `convex/messages/access.ts` has a + function reference of `api.messages.access.h`. ### Api design -- Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory. + +- Convex uses file-based routing, so thoughtfully organize files with public query, + mutation, or action functions within the `convex/` directory. - Use `query`, `mutation`, and `action` to define public functions. -- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions. +- Use `internalQuery`, `internalMutation`, and `internalAction` to define private, + internal functions. ### Pagination + - Paginated queries are queries that return a list of results in incremental pages. - You can define pagination using the following syntax: ```ts -import { v } from "convex/values"; -import { query, mutation } from "./_generated/server"; -import { paginationOptsValidator } from "convex/server"; +import {v} from "convex/values"; +import {query, mutation} from "./_generated/server"; +import {paginationOptsValidator} from "convex/server"; + export const listWithExtraArg = query({ - args: { paginationOpts: paginationOptsValidator, author: v.string() }, - handler: async (ctx, args) => { - return await ctx.db - .query("messages") - .filter((q) => q.eq(q.field("author"), args.author)) - .order("desc") - .paginate(args.paginationOpts); - }, + args: {paginationOpts: paginationOptsValidator, author: v.string()}, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .filter((q) => q.eq(q.field("author"), args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, }); ``` -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) - - continueCursor (a string that represents the cursor to use to fetch the next page of documents) +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) + - continueCursor (a string that represents the cursor to use to fetch the next page + of documents) ## Validator guidelines -- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead. -- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported. + +- `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` + instead. +- Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not + supported. ## 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. 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. +- 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. ## Typescript guidelines -- You can use the helper typescript type `Id` imported from './_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. -- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: + +- You can use the helper typescript type `Id` imported from './_generated/dataModel' to + get the type of the id for a given table. For example if there is a table called ' + users' you can use `Id<'users'>` to get the type of the id for that table. +- If you need to define a `Record` make sure that you correctly provide the type of the + key and value in the type. For example a validator + `v.record(v.id('users'), v.string())` would have the type + `Record, string>`. Below is an example of using `Record` with an `Id` type + in a query: + ```ts -import { query } from "./_generated/server"; -import { Doc, Id } from "./_generated/dataModel"; +import {query} from "./_generated/server"; +import {Doc, Id} from "./_generated/dataModel"; export const exampleQuery = query({ - args: { userIds: v.array(v.id("users")) }, - returns: v.record(v.id("users"), v.string()), - handler: async (ctx, args) => { - const idToUsername: Record, string> = {}; - for (const userId of args.userIds) { - const user = await ctx.db.get("users", userId); - if (user) { - idToUsername[user._id] = user.username; - } - } + args: {userIds: v.array(v.id("users"))}, + returns: v.record(v.id("users"), v.string()), + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get("users", userId); + if (user) { + idToUsername[user._id] = user.username; + } + } - return idToUsername; - }, + return idToUsername; + }, }); ``` -- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. + +- Be strict with types, particularly around id's of documents. For example, if a + function takes in an id for a document in the 'users' table, take in `Id<'users'>` + rather than `string`. - Always use `as const` for string literals in discriminated union types. -- When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];` -- When using the `Record` type, make sure to always define your records as `const record: Record = {...};` -- Always add `@types/node` to your `package.json` when using any Node.js built-in modules. +- When using the `Array` type, make sure to always define your arrays as + `const array: Array = [...];` +- When using the `Record` type, make sure to always define your records as + `const record: Record = {...};` +- Always add `@types/node` to your `package.json` when using any Node.js built-in + modules. ## Full text search guidelines -- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: + +- A query for "10 messages in channel '#general' that best match the query 'hello hi' in + their body" would look like: const messages = await ctx.db .query("messages") @@ -246,46 +455,68 @@ q.search("body", "hello hi").eq("channel", "#general"), .take(10); ## Query guidelines -- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. -- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result. -- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. -- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. -### Ordering -- By default Convex always returns documents in ascending `_creationTime` order. -- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. -- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. +- Do NOT use `filter` in queries. Instead, define an index in the schema and use + `withIndex` instead. +- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate + over them, and call `ctx.db.delete(row._id)` on each result. +- Use `.unique()` to get a single document from a query. This method will throw an error + if there are multiple documents that match the query. +- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a + query. Instead, use the `for await (const row of query)` syntax. + +### Ordering + +- By default Convex always returns documents in ascending `_creationTime` order. +- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in + ascending or descending order. If the order isn't specified, it defaults to ascending. +- Document queries that use indexes will be ordered based on the columns in the index + and can avoid slow table scans. ## Mutation guidelines -- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` -- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` + +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an + error if the document does not exist. Syntax: + `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method + will throw an error if the document does not exist. Syntax: + `await ctx.db.patch('tasks', taskId, { completed: true })` ## Action guidelines -- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. + +- Always add `"use node";` to the top of files containing actions that use Node.js + built-in modules. - Never use `ctx.db` inside of an action. Actions don't have access to the database. - Below is an example of the syntax for an action: + ```ts -import { action } from "./_generated/server"; +import {action} from "./_generated/server"; export const exampleAction = action({ - args: {}, - returns: v.null(), - handler: async (ctx, args) => { - console.log("This action does not return anything"); - return null; - }, + args: {}, + returns: v.null(), + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, }); ``` ## Scheduling guidelines + ### Cron guidelines -- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. -- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. -- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, + +- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT + use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. +- Both cron methods take in a FunctionReference. Do NOT try to pass the function + directly into one of these methods. +- Define crons by declaring the top-level `crons` object, calling some methods on it, + and then exporting it as default. For example, + ```ts -import { cronJobs } from "convex/server"; -import { internal } from "./_generated/api"; -import { internalAction } from "./_generated/server"; +import {cronJobs} from "convex/server"; +import {internal} from "./_generated/api"; +import {internalAction} from "./_generated/server"; const empty = internalAction({ args: {}, @@ -298,20 +529,25 @@ const empty = internalAction({ const crons = cronJobs(); // Run `internal.crons.empty` every two hours. -crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); +crons.interval("delete inactive users", {hours: 2}, internal.crons.empty, {}); export default crons; ``` -- You can register Convex functions within `crons.ts` just like any other file. -- If a cron calls an internal function, always import the `internal` object from '_generated/api', even if the internal function is registered in the same file. +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '_ + generated/api', even if the internal function is registered in the same file. ## File storage guidelines + - Convex includes file storage for large files like images, videos, and PDFs. -- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. -- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. +- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns + `null` if the file doesn't exist. +- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's + metadata. Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. + ``` import { query } from "./_generated/server"; import { Id } from "./_generated/dataModel"; @@ -334,424 +570,6 @@ export const exampleQuery = query({ }, }); ``` -- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. - -# Examples: -## Example: chat-app - -### Task -``` -Create a real-time chat application backend with AI responses. The app should: -- Allow creating users with names -- Support multiple chat channels -- Enable users to send messages to channels -- Automatically generate AI responses to user messages -- Show recent message history - -The backend should provide APIs for: -1. User management (creation) -2. Channel management (creation) -3. Message operations (sending, listing) -4. AI response generation using OpenAI's GPT-4 - -Messages should be stored with their channel, author, and content. The system should maintain message order -and limit history display to the 10 most recent messages per channel. - -``` - -### Analysis -1. Task Requirements Summary: -- Build a real-time chat backend with AI integration -- Support user creation -- Enable channel-based conversations -- Store and retrieve messages with proper ordering -- Generate AI responses automatically - -2. Main Components Needed: -- Database tables: users, channels, messages -- Public APIs for user/channel management -- Message handling functions -- Internal AI response generation system -- Context loading for AI responses - -3. Public API and Internal Functions Design: - Public Mutations: -- createUser: - - file path: convex/index.ts - - arguments: {name: v.string()} - - returns: v.object({userId: v.id("users")}) - - purpose: Create a new user with a given name -- createChannel: - - file path: convex/index.ts - - arguments: {name: v.string()} - - returns: v.object({channelId: v.id("channels")}) - - purpose: Create a new channel with a given name -- sendMessage: - - file path: convex/index.ts - - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()} - - returns: v.null() - - purpose: Send a message to a channel and schedule a response from the AI - -Public Queries: -- listMessages: - - file path: convex/index.ts - - arguments: {channelId: v.id("channels")} - - returns: v.array(v.object({ - _id: v.id("messages"), - _creationTime: v.number(), - channelId: v.id("channels"), - authorId: v.optional(v.id("users")), - content: v.string(), - })) - - purpose: List the 10 most recent messages from a channel in descending creation order - -Internal Functions: -- generateResponse: - - file path: convex/index.ts - - arguments: {channelId: v.id("channels")} - - returns: v.null() - - purpose: Generate a response from the AI for a given channel -- loadContext: - - file path: convex/index.ts - - arguments: {channelId: v.id("channels")} - - returns: v.array(v.object({ - _id: v.id("messages"), - _creationTime: v.number(), - channelId: v.id("channels"), - authorId: v.optional(v.id("users")), - content: v.string(), - })) -- writeAgentResponse: - - file path: convex/index.ts - - arguments: {channelId: v.id("channels"), content: v.string()} - - returns: v.null() - - purpose: Write an AI response to a given channel - -4. Schema Design: -- users - - validator: { name: v.string() } - - indexes: -- channels - - validator: { name: v.string() } - - indexes: -- messages - - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() } - - indexes - - by_channel: ["channelId"] - -5. Background Processing: -- AI response generation runs asynchronously after each user message -- Uses OpenAI's GPT-4 to generate contextual responses -- Maintains conversation context using recent message history - - -### Implementation - -#### package.json -```typescript -{ - "name": "chat-app", - "description": "This example shows how to build a chat app without authentication.", - "version": "1.0.0", - "dependencies": { - "convex": "^1.31.2", - "openai": "^4.79.0" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} -``` - -#### tsconfig.json -```typescript -{ - "compilerOptions": { - "target": "ESNext", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "moduleResolution": "Bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "allowImportingTsExtensions": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "exclude": ["convex"], - "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"] -} -``` - -#### convex/index.ts -```typescript -import { - query, - mutation, - internalQuery, - internalMutation, - internalAction, -} from "./_generated/server"; -import { v } from "convex/values"; -import OpenAI from "openai"; -import { internal } from "./_generated/api"; - -/** - * Create a user with a given name. - */ -export const createUser = mutation({ - args: { - name: v.string(), - }, - returns: v.id("users"), - handler: async (ctx, args) => { - return await ctx.db.insert("users", { name: args.name }); - }, -}); - -/** - * Create a channel with a given name. - */ -export const createChannel = mutation({ - args: { - name: v.string(), - }, - returns: v.id("channels"), - handler: async (ctx, args) => { - return await ctx.db.insert("channels", { name: args.name }); - }, -}); - -/** - * List the 10 most recent messages from a channel in descending creation order. - */ -export const listMessages = query({ - args: { - channelId: v.id("channels"), - }, - returns: v.array( - v.object({ - _id: v.id("messages"), - _creationTime: v.number(), - channelId: v.id("channels"), - authorId: v.optional(v.id("users")), - content: v.string(), - }), - ), - handler: async (ctx, args) => { - const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) - .order("desc") - .take(10); - return messages; - }, -}); - -/** - * Send a message to a channel and schedule a response from the AI. - */ -export const sendMessage = mutation({ - args: { - channelId: v.id("channels"), - authorId: v.id("users"), - content: v.string(), - }, - returns: v.null(), - handler: async (ctx, args) => { - const channel = await ctx.db.get(args.channelId); - if (!channel) { - throw new Error("Channel not found"); - } - const user = await ctx.db.get(args.authorId); - if (!user) { - throw new Error("User not found"); - } - await ctx.db.insert("messages", { - channelId: args.channelId, - authorId: args.authorId, - content: args.content, - }); - await ctx.scheduler.runAfter(0, internal.index.generateResponse, { - channelId: args.channelId, - }); - return null; - }, -}); - -const openai = new OpenAI(); - -export const generateResponse = internalAction({ - args: { - channelId: v.id("channels"), - }, - returns: v.null(), - handler: async (ctx, args) => { - const context = await ctx.runQuery(internal.index.loadContext, { - channelId: args.channelId, - }); - const response = await openai.chat.completions.create({ - model: "gpt-4o", - messages: context, - }); - const content = response.choices[0].message.content; - if (!content) { - throw new Error("No content in response"); - } - await ctx.runMutation(internal.index.writeAgentResponse, { - channelId: args.channelId, - content, - }); - return null; - }, -}); - -export const loadContext = internalQuery({ - args: { - channelId: v.id("channels"), - }, - returns: v.array( - v.object({ - role: v.union(v.literal("user"), v.literal("assistant")), - content: v.string(), - }), - ), - handler: async (ctx, args) => { - const channel = await ctx.db.get(args.channelId); - if (!channel) { - throw new Error("Channel not found"); - } - const messages = await ctx.db - .query("messages") - .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) - .order("desc") - .take(10); - - const result = []; - for (const message of messages) { - if (message.authorId) { - const user = await ctx.db.get(message.authorId); - if (!user) { - throw new Error("User not found"); - } - result.push({ - role: "user" as const, - content: `${user.name}: ${message.content}`, - }); - } else { - result.push({ role: "assistant" as const, content: message.content }); - } - } - return result; - }, -}); - -export const writeAgentResponse = internalMutation({ - args: { - channelId: v.id("channels"), - content: v.string(), - }, - returns: v.null(), - handler: async (ctx, args) => { - await ctx.db.insert("messages", { - channelId: args.channelId, - content: args.content, - }); - return null; - }, -}); -``` - -#### convex/schema.ts -```typescript -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - channels: defineTable({ - name: v.string(), - }), - - users: defineTable({ - name: v.string(), - }), - - messages: defineTable({ - channelId: v.id("channels"), - authorId: v.optional(v.id("users")), - content: v.string(), - }).index("by_channel", ["channelId"]), -}); -``` - -#### convex/tsconfig.json -```typescript -{ - /* This TypeScript project config describes the environment that - * Convex functions run in and is used to typecheck them. - * You can modify it, but some settings required to use Convex. - */ - "compilerOptions": { - /* These settings are not required by Convex and can be modified. */ - "allowJs": true, - "strict": true, - "moduleResolution": "Bundler", - "jsx": "react-jsx", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - - /* These compiler options are required by Convex */ - "target": "ESNext", - "lib": ["ES2021", "dom"], - "forceConsistentCasingInFileNames": true, - "module": "ESNext", - "isolatedModules": true, - "noEmit": true - }, - "include": ["./**/*"], - "exclude": ["./_generated"] -} -``` - -#### src/routes/+layout.svelte -```sveltehtml - - -{@render children()} -``` - -#### src/routes/+page.svelte -```sveltehtml - - -{#if query.isLoading} - Loading... -{:else if query.error} - failed to load: {query.error.toString()} -{:else} -
    - {#each query.data as task} -
  • - {task.isCompleted ? '☑' : '☐'} - {task.text} - assigned by {task.assigner} -
  • - {/each} -
-{/if} -``` +- Convex storage stores items as `Blob` objects. You must convert all items to/from a + `Blob` when using Convex storage. diff --git a/backend/.env.example b/backend/.env.example index 67284f5..0607ac7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,7 @@ BOT__TOKEN= +SITE__URL= + LOG__LEVEL=INFO LOG__LEVEL_EXTERNAL=WARNING LOG__SHOW_TIME=false diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4d2856e..cc26111 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "pydantic-ai-slim[google]>=1.44.0", "pydantic-settings>=2.12.0", "rich>=14.2.0", + "xkcdpass>=1.19.0", ] [build-system] diff --git a/backend/src/bot/__init__.py b/backend/src/bot/__init__.py index 2f9efa5..442ded8 100644 --- a/backend/src/bot/__init__.py +++ b/backend/src/bot/__init__.py @@ -11,11 +11,20 @@ setup_logging() async def runner() -> None: from . import handlers # noqa: PLC0415 from .common import bot, dp # noqa: PLC0415 + from .sync import start_sync_listener # noqa: PLC0415 dp.include_routers(handlers.router) + sync_task = asyncio.create_task(start_sync_listener(bot)) + await bot.delete_webhook(drop_pending_updates=True) - await dp.start_polling(bot) + + try: + await dp.start_polling(bot) + finally: + sync_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await sync_task def plugins() -> None: diff --git a/backend/src/bot/handlers/__init__.py b/backend/src/bot/handlers/__init__.py index 9253508..e84d8fa 100644 --- a/backend/src/bot/handlers/__init__.py +++ b/backend/src/bot/handlers/__init__.py @@ -1,7 +1,9 @@ from aiogram import Router -from . import initialize, start +from . import apikey, chat, initialize, message, start router = Router() -router.include_routers(start.router, initialize.router) +router.include_routers( + start.router, initialize.router, apikey.router, chat.router, message.router +) diff --git a/backend/src/bot/handlers/apikey/__init__.py b/backend/src/bot/handlers/apikey/__init__.py new file mode 100644 index 0000000..c33d170 --- /dev/null +++ b/backend/src/bot/handlers/apikey/__init__.py @@ -0,0 +1,3 @@ +from .handler import router + +__all__ = ["router"] diff --git a/backend/src/bot/handlers/apikey/handler.py b/backend/src/bot/handlers/apikey/handler.py new file mode 100644 index 0000000..4ca2b8f --- /dev/null +++ b/backend/src/bot/handlers/apikey/handler.py @@ -0,0 +1,33 @@ +from aiogram import Router, types +from aiogram.filters import Command +from convex import ConvexInt64 + +from utils import env +from utils.convex import ConvexClient + +router = Router() +convex = ConvexClient(env.convex_url) + + +@router.message(Command("apikey")) +async def on_apikey(message: types.Message) -> None: + if not message.from_user: + return + + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: # noqa: PLR2004 + await message.answer( + "Usage: /apikey YOUR_GEMINI_API_KEY\n\n" + "Get your API key at https://aistudio.google.com/apikey" + ) + return + + api_key = args[1].strip() + + user_id = await convex.mutation( + "users:getOrCreate", {"telegramId": ConvexInt64(message.from_user.id)} + ) + await convex.mutation("users:setApiKey", {"userId": user_id, "apiKey": api_key}) + + await message.delete() + await message.answer("✓ API key saved. Use /new to create a chat.") diff --git a/backend/src/bot/handlers/chat/__init__.py b/backend/src/bot/handlers/chat/__init__.py new file mode 100644 index 0000000..de86960 --- /dev/null +++ b/backend/src/bot/handlers/chat/__init__.py @@ -0,0 +1,3 @@ +from .handlers import router + +__all__ = ["router"] diff --git a/backend/src/bot/handlers/chat/handlers.py b/backend/src/bot/handlers/chat/handlers.py new file mode 100644 index 0000000..3682bcd --- /dev/null +++ b/backend/src/bot/handlers/chat/handlers.py @@ -0,0 +1,155 @@ +from aiogram import Router, types +from aiogram.filters import Command +from convex import ConvexInt64 + +from bot.modules.ai import PRESETS +from bot.modules.mnemonic import generate_mnemonic +from utils import env +from utils.convex import ConvexClient + +router = Router() +convex = ConvexClient(env.convex_url) + + +@router.message(Command("new")) +async def on_new(message: types.Message) -> None: + if not message.from_user: + return + + user = await convex.query( + "users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)} + ) + + if not user: + await message.answer("Use /apikey first to set your Gemini API key.") + return + + if not user.get("geminiApiKey"): + await message.answer("Use /apikey first to set your Gemini API key.") + return + + mnemonic = generate_mnemonic() + chat_id = await convex.mutation( + "chats:create", {"userId": user["_id"], "mnemonic": mnemonic} + ) + await convex.mutation( + "users:setActiveChat", {"userId": user["_id"], "chatId": chat_id} + ) + + url = f"{env.site.url}/{mnemonic}" + await message.answer(f"New chat created!\n\n{url}", parse_mode="HTML") + + +@router.message(Command("clear")) +async def on_clear(message: types.Message) -> None: + if not message.from_user: + return + + user = await convex.query( + "users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)} + ) + + if not user or not user.get("activeChatId"): + await message.answer("No active chat. Use /new to create one.") + return + + await convex.mutation("chats:clear", {"chatId": user["activeChatId"]}) + await message.answer("✓ Chat history cleared.") + + +@router.message(Command("prompt")) +async def on_prompt(message: types.Message) -> None: + if not message.from_user: + return + + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: # noqa: PLR2004 + await message.answer( + "Usage: /prompt YOUR_SYSTEM_PROMPT\n\n" + "Example: /prompt You are a helpful math tutor." + ) + return + + prompt = args[1].strip() + + user_id = await convex.mutation( + "users:getOrCreate", {"telegramId": ConvexInt64(message.from_user.id)} + ) + await convex.mutation( + "users:setSystemPrompt", {"userId": user_id, "prompt": prompt} + ) + + await message.answer("✓ System prompt updated.") + + +@router.message(Command("model")) +async def on_model(message: types.Message) -> None: + if not message.from_user: + return + + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: # noqa: PLR2004 + await message.answer( + "Usage: /model MODEL_NAME\n\n" + "Available models:\n" + "• gemini-2.5-pro-preview-05-06 (default)\n" + "• gemini-2.5-flash-preview-05-20\n" + "• gemini-2.0-flash" + ) + return + + model = args[1].strip() + + user_id = await convex.mutation( + "users:getOrCreate", {"telegramId": ConvexInt64(message.from_user.id)} + ) + await convex.mutation("users:setModel", {"userId": user_id, "model": model}) + + await message.answer(f"✓ Model set to {model}") + + +@router.message(Command("presets")) +async def on_presets(message: types.Message) -> None: + if not message.from_user: + return + + lines = ["Available presets:\n"] + lines.extend(f"• /preset {name}" for name in PRESETS) + lines.append("\nUse /preset NAME to apply a preset.") + await message.answer("\n".join(lines), parse_mode="HTML") + + +@router.message(Command("preset")) +async def on_preset(message: types.Message) -> None: + if not message.from_user: + return + + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: # noqa: PLR2004 + await message.answer( + "Usage: /preset NAME\n\nUse /presets to see available presets." + ) + return + + preset_name = args[1].strip().lower() + preset = PRESETS.get(preset_name) + + if not preset: + await message.answer( + f"Unknown preset: {preset_name}\n\nUse /presets to see available presets." + ) + return + + system_prompt, follow_up_prompt = preset + + user_id = await convex.mutation( + "users:getOrCreate", {"telegramId": ConvexInt64(message.from_user.id)} + ) + await convex.mutation( + "users:setSystemPrompt", {"userId": user_id, "prompt": system_prompt} + ) + await convex.mutation( + "users:setFollowUpPrompt", {"userId": user_id, "prompt": follow_up_prompt} + ) + + await message.answer(f"✓ Preset '{preset_name}' applied.") diff --git a/backend/src/bot/handlers/initialize/initializer.py b/backend/src/bot/handlers/initialize/initializer.py index 74d4df8..473cc50 100644 --- a/backend/src/bot/handlers/initialize/initializer.py +++ b/backend/src/bot/handlers/initialize/initializer.py @@ -8,7 +8,16 @@ router = Router() @router.startup() async def startup(bot: Bot) -> None: await bot.set_my_commands( - [types.BotCommand(command="/start", description="Start bot")] + [ + types.BotCommand(command="/start", description="Start bot"), + types.BotCommand(command="/apikey", description="Set Gemini API key"), + types.BotCommand(command="/new", description="Create new chat"), + types.BotCommand(command="/clear", description="Clear chat history"), + types.BotCommand(command="/prompt", description="Set system prompt"), + types.BotCommand(command="/model", description="Change AI model"), + types.BotCommand(command="/presets", description="Show prompt presets"), + types.BotCommand(command="/preset", description="Apply a preset"), + ] ) logger.info(f"[green]Started as[/] @{(await bot.me()).username}") diff --git a/backend/src/bot/handlers/message/__init__.py b/backend/src/bot/handlers/message/__init__.py new file mode 100644 index 0000000..c33d170 --- /dev/null +++ b/backend/src/bot/handlers/message/__init__.py @@ -0,0 +1,3 @@ +from .handler import router + +__all__ = ["router"] diff --git a/backend/src/bot/handlers/message/handler.py b/backend/src/bot/handlers/message/handler.py new file mode 100644 index 0000000..9ab5719 --- /dev/null +++ b/backend/src/bot/handlers/message/handler.py @@ -0,0 +1,401 @@ +import asyncio +import contextlib +import io +import time + +from aiogram import Bot, F, Router, html, types +from aiogram.enums import ChatAction +from aiogram.types import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove +from convex import ConvexInt64 + +from bot.modules.ai import ( + SUMMARIZE_PROMPT, + ImageData, + create_follow_up_agent, + create_text_agent, + get_follow_ups, + stream_response, +) +from utils import env +from utils.convex import ConvexClient + +router = Router() +convex = ConvexClient(env.convex_url) + +EDIT_THROTTLE_SECONDS = 1.0 +TELEGRAM_MAX_LENGTH = 4096 + + +def make_follow_up_keyboard(options: list[str]) -> ReplyKeyboardMarkup: + buttons = [[KeyboardButton(text=opt)] for opt in options] + return ReplyKeyboardMarkup( + keyboard=buttons, resize_keyboard=True, one_time_keyboard=True + ) + + +def split_message(text: str, max_length: int = TELEGRAM_MAX_LENGTH) -> list[str]: + if len(text) <= max_length: + return [text] + + parts: list[str] = [] + while text: + if len(text) <= max_length: + parts.append(text) + break + + split_pos = text.rfind("\n", 0, max_length) + if split_pos == -1: + split_pos = text.rfind(" ", 0, max_length) + if split_pos == -1: + split_pos = max_length + + parts.append(text[:split_pos]) + text = text[split_pos:].lstrip() + + return parts + + +class StreamingState: + def __init__(self, bot: Bot, chat_id: int, message: types.Message) -> None: + self.bot = bot + self.chat_id = chat_id + self.message = message + self.last_edit_time = 0.0 + self.last_content = "" + self.pending_content: str | None = None + self._typing_task: asyncio.Task[None] | None = None + + async def start_typing(self) -> None: + async def typing_loop() -> None: + while True: + await self.bot.send_chat_action(self.chat_id, ChatAction.TYPING) + await asyncio.sleep(4) + + self._typing_task = asyncio.create_task(typing_loop()) + + async def stop_typing(self) -> None: + if self._typing_task: + self._typing_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._typing_task + + async def update_message(self, content: str, *, force: bool = False) -> None: + if content == self.last_content: + return + + if len(content) > TELEGRAM_MAX_LENGTH: + display_content = content[: TELEGRAM_MAX_LENGTH - 3] + "..." + else: + display_content = content + + now = time.monotonic() + if force or (now - self.last_edit_time) >= EDIT_THROTTLE_SECONDS: + with contextlib.suppress(Exception): + await self.message.edit_text(html.quote(display_content)) + self.last_edit_time = now + self.last_content = content + self.pending_content = None + else: + self.pending_content = content + + async def flush(self) -> None: + if self.pending_content and self.pending_content != self.last_content: + await self.update_message(self.pending_content, force=True) + + +async def send_long_message( + bot: Bot, chat_id: int, text: str, reply_markup: ReplyKeyboardMarkup | None = None +) -> None: + parts = split_message(text) + for i, part in enumerate(parts): + is_last = i == len(parts) - 1 + await bot.send_message( + chat_id, html.quote(part), reply_markup=reply_markup if is_last else None + ) + + +async def process_message_from_web( # noqa: C901, PLR0915 + convex_user_id: str, text: str, bot: Bot, convex_chat_id: str +) -> None: + user = await convex.query("users:getById", {"userId": convex_user_id}) + + if not user or not user.get("geminiApiKey"): + return + + tg_chat_id = user["telegramChatId"].value if user.get("telegramChatId") else None + is_summarize = text == "/summarize" + + if tg_chat_id and not is_summarize: + await bot.send_message( + tg_chat_id, f"📱 {html.quote(text)}", reply_markup=ReplyKeyboardRemove() + ) + + api_key = user["geminiApiKey"] + model_name = user.get("model", "gemini-3-pro-preview") + + assistant_message_id = await convex.mutation( + "messages:create", + { + "chatId": convex_chat_id, + "role": "assistant", + "content": "", + "source": "web", + "isStreaming": True, + }, + ) + + history = await convex.query( + "messages:getHistoryForAI", {"chatId": convex_chat_id, "limit": 50} + ) + + system_prompt = SUMMARIZE_PROMPT if is_summarize else user.get("systemPrompt") + text_agent = create_text_agent( + api_key=api_key, model_name=model_name, system_prompt=system_prompt + ) + + processing_msg = None + state = None + if tg_chat_id: + processing_msg = await bot.send_message(tg_chat_id, "...") + state = StreamingState(bot, tg_chat_id, processing_msg) + + try: + if state: + await state.start_typing() + + async def on_chunk(content: str) -> None: + if state: + await state.update_message(content) + await convex.mutation( + "messages:update", + {"messageId": assistant_message_id, "content": content}, + ) + + if is_summarize: + prompt_text = "Summarize what was done in this conversation." + hist = history[:-2] + else: + prompt_text = text + hist = history[:-1] + + final_answer = await stream_response(text_agent, prompt_text, hist, on_chunk) + + if state: + await state.flush() + + full_history = [*history, {"role": "assistant", "content": final_answer}] + follow_up_model = user.get("followUpModel", "gemini-2.5-flash-lite") + follow_up_prompt = user.get("followUpPrompt") + follow_up_agent = create_follow_up_agent( + api_key=api_key, model_name=follow_up_model, system_prompt=follow_up_prompt + ) + follow_ups = await get_follow_ups(follow_up_agent, full_history) + + if state: + await state.stop_typing() + + await convex.mutation( + "messages:update", + { + "messageId": assistant_message_id, + "content": final_answer, + "followUpOptions": follow_ups, + "isStreaming": False, + }, + ) + + if tg_chat_id and processing_msg: + with contextlib.suppress(Exception): + await processing_msg.delete() + keyboard = make_follow_up_keyboard(follow_ups) + await send_long_message(bot, tg_chat_id, final_answer, keyboard) + + except Exception as e: # noqa: BLE001 + if state: + await state.stop_typing() + error_msg = f"Error: {e}" + await convex.mutation( + "messages:update", + { + "messageId": assistant_message_id, + "content": error_msg, + "isStreaming": False, + }, + ) + if tg_chat_id and processing_msg: + with contextlib.suppress(Exception): + truncated = html.quote(error_msg[:TELEGRAM_MAX_LENGTH]) + await processing_msg.edit_text(truncated) + + +async def process_message( + user_id: int, text: str, bot: Bot, chat_id: int, image: ImageData | None = None +) -> None: + user = await convex.query( + "users:getByTelegramId", {"telegramId": ConvexInt64(user_id)} + ) + + if not user: + await bot.send_message(chat_id, "Use /apikey first to set your Gemini API key.") + return + + if not user.get("geminiApiKey"): + await bot.send_message(chat_id, "Use /apikey first to set your Gemini API key.") + return + + if not user.get("activeChatId"): + await bot.send_message(chat_id, "Use /new first to create a chat.") + return + + active_chat_id = user["activeChatId"] + api_key = user["geminiApiKey"] + model_name = user.get("model", "gemini-3-pro-preview") + + await convex.mutation( + "messages:create", + { + "chatId": active_chat_id, + "role": "user", + "content": text, + "source": "telegram", + }, + ) + + assistant_message_id = await convex.mutation( + "messages:create", + { + "chatId": active_chat_id, + "role": "assistant", + "content": "", + "source": "telegram", + "isStreaming": True, + }, + ) + + history = await convex.query( + "messages:getHistoryForAI", {"chatId": active_chat_id, "limit": 50} + ) + + text_agent = create_text_agent( + api_key=api_key, model_name=model_name, system_prompt=user.get("systemPrompt") + ) + + processing_msg = await bot.send_message(chat_id, "...") + state = StreamingState(bot, chat_id, processing_msg) + + try: + await state.start_typing() + + async def on_chunk(content: str) -> None: + await state.update_message(content) + await convex.mutation( + "messages:update", + {"messageId": assistant_message_id, "content": content}, + ) + + final_answer = await stream_response( + text_agent, text, history[:-2], on_chunk, image=image + ) + + await state.flush() + + full_history = [*history[:-1], {"role": "assistant", "content": final_answer}] + follow_up_model = user.get("followUpModel", "gemini-2.5-flash-lite") + follow_up_prompt = user.get("followUpPrompt") + follow_up_agent = create_follow_up_agent( + api_key=api_key, model_name=follow_up_model, system_prompt=follow_up_prompt + ) + follow_ups = await get_follow_ups(follow_up_agent, full_history, image=image) + + await state.stop_typing() + + await convex.mutation( + "messages:update", + { + "messageId": assistant_message_id, + "content": final_answer, + "followUpOptions": follow_ups, + "isStreaming": False, + }, + ) + + with contextlib.suppress(Exception): + await processing_msg.delete() + + keyboard = make_follow_up_keyboard(follow_ups) + await send_long_message(bot, chat_id, final_answer, keyboard) + + except Exception as e: # noqa: BLE001 + await state.stop_typing() + error_msg = f"Error: {e}" + await convex.mutation( + "messages:update", + { + "messageId": assistant_message_id, + "content": error_msg, + "isStreaming": False, + }, + ) + with contextlib.suppress(Exception): + await processing_msg.edit_text(html.quote(error_msg[:TELEGRAM_MAX_LENGTH])) + + +async def send_to_telegram(user_id: int, text: str, bot: Bot) -> None: + user = await convex.query( + "users:getByTelegramId", {"telegramId": ConvexInt64(user_id)} + ) + if not user or not user.get("telegramChatId"): + return + + tg_chat_id = user["telegramChatId"] + await bot.send_message( + tg_chat_id, f"📱 {html.quote(text)}", reply_markup=ReplyKeyboardRemove() + ) + + +@router.message(F.text & ~F.text.startswith("/")) +async def on_text_message(message: types.Message, bot: Bot) -> None: + if not message.from_user or not message.text: + return + await convex.mutation( + "users:getOrCreate", + { + "telegramId": ConvexInt64(message.from_user.id), + "telegramChatId": ConvexInt64(message.chat.id), + }, + ) + await process_message(message.from_user.id, message.text, bot, message.chat.id) + + +@router.message(F.photo) +async def on_photo_message(message: types.Message, bot: Bot) -> None: + if not message.from_user or not message.photo: + return + + await convex.mutation( + "users:getOrCreate", + { + "telegramId": ConvexInt64(message.from_user.id), + "telegramChatId": ConvexInt64(message.chat.id), + }, + ) + + caption = message.caption or "Process the image according to your task" + photo = message.photo[-1] + + file = await bot.get_file(photo.file_id) + if not file.file_path: + await message.answer("Failed to get photo.") + return + + buffer = io.BytesIO() + await bot.download_file(file.file_path, buffer) + image_bytes = buffer.getvalue() + + ext = file.file_path.rsplit(".", 1)[-1].lower() + media_type = f"image/{ext}" if ext in ("png", "gif", "webp") else "image/jpeg" + image = ImageData(data=image_bytes, media_type=media_type) + + await process_message( + message.from_user.id, caption, bot, message.chat.id, image=image + ) diff --git a/backend/src/bot/handlers/start/start.py b/backend/src/bot/handlers/start/start.py index 3688e0b..3e1510e 100644 --- a/backend/src/bot/handlers/start/start.py +++ b/backend/src/bot/handlers/start/start.py @@ -3,7 +3,23 @@ from aiogram.filters import CommandStart router = Router() +WELCOME_MESSAGE = """ +Welcome to AI Chat! + +Get started: +1. /apikey YOUR_KEY — Set your Gemini API key +2. /new — Create a new chat and get your Watch URL + +Commands: +• /clear — Clear chat history +• /prompt — Set custom system prompt +• /model — Change AI model +• /presets — Show available presets + +Get your API key at https://aistudio.google.com/apikey +""".strip() + @router.message(CommandStart()) async def on_start(message: types.Message) -> None: - await message.answer("hi") + await message.answer(WELCOME_MESSAGE, parse_mode="HTML") diff --git a/backend/src/bot/modules/__init__.py b/backend/src/bot/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/bot/modules/ai/__init__.py b/backend/src/bot/modules/ai/__init__.py new file mode 100644 index 0000000..ef8ddd5 --- /dev/null +++ b/backend/src/bot/modules/ai/__init__.py @@ -0,0 +1,21 @@ +from .agent import ( + ImageData, + StreamCallback, + create_follow_up_agent, + create_text_agent, + get_follow_ups, + stream_response, +) +from .prompts import DEFAULT_FOLLOW_UP, PRESETS, SUMMARIZE_PROMPT + +__all__ = [ + "DEFAULT_FOLLOW_UP", + "PRESETS", + "SUMMARIZE_PROMPT", + "ImageData", + "StreamCallback", + "create_follow_up_agent", + "create_text_agent", + "get_follow_ups", + "stream_response", +] diff --git a/backend/src/bot/modules/ai/agent.py b/backend/src/bot/modules/ai/agent.py new file mode 100644 index 0000000..0603bd8 --- /dev/null +++ b/backend/src/bot/modules/ai/agent.py @@ -0,0 +1,115 @@ +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pydantic_ai import ( + Agent, + BinaryContent, + ModelMessage, + ModelRequest, + ModelResponse, + TextPart, + UserPromptPart, +) +from pydantic_ai.models.google import GoogleModel +from pydantic_ai.providers.google import GoogleProvider + +from .models import FollowUpOptions +from .prompts import DEFAULT_FOLLOW_UP + +StreamCallback = Callable[[str], Awaitable[None]] + + +@dataclass +class ImageData: + data: bytes + media_type: str + + +LATEX_INSTRUCTION = "For math, use LaTeX: $...$ inline, $$...$$ display." + +DEFAULT_SYSTEM_PROMPT = ( + "You are a helpful AI assistant. Provide clear, concise answers." +) + + +def create_text_agent( + api_key: str, + model_name: str = "gemini-3-pro-preview", + system_prompt: str | None = None, +) -> Agent[None, str]: + provider = GoogleProvider(api_key=api_key) + model = GoogleModel(model_name, provider=provider) + base_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT + full_prompt = f"{base_prompt} {LATEX_INSTRUCTION}" + return Agent(model, system_prompt=full_prompt) + + +def create_follow_up_agent( + api_key: str, + model_name: str = "gemini-2.5-flash-lite", + system_prompt: str | None = None, +) -> Agent[None, FollowUpOptions]: + provider = GoogleProvider(api_key=api_key) + model = GoogleModel(model_name, provider=provider) + prompt = system_prompt or DEFAULT_FOLLOW_UP + return Agent(model, output_type=FollowUpOptions, system_prompt=prompt) + + +def build_message_history(history: list[dict[str, str]]) -> list[ModelMessage]: + messages: list[ModelMessage] = [] + for msg in history: + if msg["role"] == "user": + messages.append( + ModelRequest(parts=[UserPromptPart(content=msg["content"])]) + ) + else: + messages.append(ModelResponse(parts=[TextPart(content=msg["content"])])) + return messages + + +async def stream_response( # noqa: PLR0913 + text_agent: Agent[None, str], + message: str, + history: list[dict[str, str]] | None = None, + on_chunk: StreamCallback | None = None, + image: ImageData | None = None, + images: list[ImageData] | None = None, +) -> str: + message_history = build_message_history(history) if history else None + + all_images = images or ([image] if image else []) + + if all_images: + prompt: list[str | BinaryContent] = [message] + prompt.extend( + BinaryContent(data=img.data, media_type=img.media_type) + for img in all_images + ) + else: + prompt = message # type: ignore[assignment] + + stream = text_agent.run_stream(prompt, message_history=message_history) + async with stream as result: + async for text in result.stream_text(): + if on_chunk: + await on_chunk(text) + return await result.get_output() + + +async def get_follow_ups( + follow_up_agent: Agent[None, FollowUpOptions], + history: list[dict[str, str]], + image: ImageData | None = None, +) -> list[str]: + message_history = build_message_history(history) if history else None + + if image: + prompt: list[str | BinaryContent] = [ + "Suggest follow-up options based on this conversation and image.", + BinaryContent(data=image.data, media_type=image.media_type), + ] + else: + prompt = "Suggest follow-up questions based on this conversation." # type: ignore[assignment] + + result = await follow_up_agent.run(prompt, message_history=message_history) + return result.output["options"] diff --git a/backend/src/bot/modules/ai/models.py b/backend/src/bot/modules/ai/models.py new file mode 100644 index 0000000..601552a --- /dev/null +++ b/backend/src/bot/modules/ai/models.py @@ -0,0 +1,10 @@ +from typing import TypedDict + + +class AIResponse(TypedDict): + answer: str + follow_up_options: list[str] + + +class FollowUpOptions(TypedDict): + options: list[str] diff --git a/backend/src/bot/modules/ai/prompts.py b/backend/src/bot/modules/ai/prompts.py new file mode 100644 index 0000000..a5b6743 --- /dev/null +++ b/backend/src/bot/modules/ai/prompts.py @@ -0,0 +1,37 @@ +EXAM_SYSTEM = """You help solve problem sets and exams. + +When you receive an IMAGE with problems: +- Give HINTS in Russian for each problem +- Focus on key insights and potential difficulties, +give all formulas that will be helpful +- Be quite concise, but include all needed hints - this will be viewed on Apple Watch +- Format: info needed to solve each problem or "unstuck" while solving + +When asked for DETAILS on a specific problem (or a problem number): +- Provide full structured solution in English +- Academic style, as it would be written in a notebook +- Step by step, clean, no fluff""" + +EXAM_FOLLOW_UP = """You see a problem set image. List available problem numbers. +Output only the numbers that exist in the image, like: 1, 2, 3, 4, 5 +If problems have letters (a, b, c), list them as: 1a, 1b, 2a, etc. +Keep it minimal - just the identifiers. +Then, if applicable, output some possible followups of conversation""" + +DEFAULT_FOLLOW_UP = ( + "Based on the conversation, suggest 3 short follow-up questions " + "the user might want to ask. Be concise, each under 50 chars." +) + +SUMMARIZE_PROMPT = """You are summarize agent. You may receive: +1. Images +2. Conversation history showing what was discussed/solved + +Summarize VERY briefly: +- Which problems were solved +- Key results or answers found +- What's left to do + +Max 2-3 sentences. This is for Apple Watch display.""" + +PRESETS: dict[str, tuple[str, str]] = {"exam": (EXAM_SYSTEM, EXAM_FOLLOW_UP)} diff --git a/backend/src/bot/modules/mnemonic/__init__.py b/backend/src/bot/modules/mnemonic/__init__.py new file mode 100644 index 0000000..80fef1b --- /dev/null +++ b/backend/src/bot/modules/mnemonic/__init__.py @@ -0,0 +1,3 @@ +from .generator import generate_mnemonic + +__all__ = ["generate_mnemonic"] diff --git a/backend/src/bot/modules/mnemonic/generator.py b/backend/src/bot/modules/mnemonic/generator.py new file mode 100644 index 0000000..103e335 --- /dev/null +++ b/backend/src/bot/modules/mnemonic/generator.py @@ -0,0 +1,8 @@ +from xkcdpass import xkcd_password as xp + +_wordfile = xp.locate_wordfile() +_wordlist = xp.generate_wordlist(wordfile=_wordfile, min_length=4, max_length=6) + + +def generate_mnemonic(word_count: int = 3, separator: str = "-") -> str: + return xp.generate_xkcdpassword(_wordlist, numwords=word_count, delimiter=separator) diff --git a/backend/src/bot/sync.py b/backend/src/bot/sync.py new file mode 100644 index 0000000..74dbd26 --- /dev/null +++ b/backend/src/bot/sync.py @@ -0,0 +1,58 @@ +import asyncio + +from aiogram import Bot + +from bot.handlers.message.handler import process_message_from_web +from utils import env +from utils.convex import ConvexClient +from utils.logging import logger + +convex = ConvexClient(env.convex_url) + + +background_tasks = set() + + +async def start_sync_listener(bot: Bot) -> None: + logger.info("Starting Convex sync listener...") + processed_ids: set[str] = set() + + sub = convex.subscribe("pendingGenerations:list", {}) + + try: + async for pending_list in sub: + for item in pending_list: + item_id = item["_id"] + if item_id in processed_ids: + continue + + processed_ids.add(item_id) + logger.info(f"Processing pending generation: {item_id}") + + task = asyncio.create_task( + handle_pending_generation(bot, item, item_id) + ) + background_tasks.add(task) + task.add_done_callback(background_tasks.discard) + + except asyncio.CancelledError: + logger.info("Sync listener cancelled") + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Sync listener error: {e}") + finally: + sub.unsubscribe() + + +async def handle_pending_generation(bot: Bot, item: dict, item_id: str) -> None: + try: + await process_message_from_web( + convex_user_id=item["userId"], + text=item["userMessage"], + bot=bot, + convex_chat_id=item["chatId"], + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Error processing {item_id}: {e}") + finally: + await convex.mutation("pendingGenerations:remove", {"id": item_id}) diff --git a/backend/src/utils/convex/__init__.py b/backend/src/utils/convex/__init__.py new file mode 100644 index 0000000..f6621a9 --- /dev/null +++ b/backend/src/utils/convex/__init__.py @@ -0,0 +1,3 @@ +from .client import ConvexClient + +__all__ = ["ConvexClient"] diff --git a/backend/src/utils/convex/client.py b/backend/src/utils/convex/client.py new file mode 100644 index 0000000..90b655d --- /dev/null +++ b/backend/src/utils/convex/client.py @@ -0,0 +1,21 @@ +import asyncio +from typing import Any + +from convex import ConvexClient as SyncConvexClient + + +class ConvexClient: + def __init__(self, url: str) -> None: + self._client = SyncConvexClient(url) + + async def query(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401 + return await asyncio.to_thread(self._client.query, name, args or {}) + + async def mutation(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401 + return await asyncio.to_thread(self._client.mutation, name, args or {}) + + async def action(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401 + return await asyncio.to_thread(self._client.action, name, args or {}) + + def subscribe(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401 + return self._client.subscribe(name, args or {}) diff --git a/backend/src/utils/env.py b/backend/src/utils/env.py index af699be..fb9b4a3 100644 --- a/backend/src/utils/env.py +++ b/backend/src/utils/env.py @@ -6,6 +6,10 @@ class BotSettings(BaseSettings): token: SecretStr +class SiteSettings(BaseSettings): + url: str = Field(default="https://localhost") + + class LogSettings(BaseSettings): level: str = "INFO" level_external: str = "WARNING" @@ -15,6 +19,7 @@ class LogSettings(BaseSettings): class Settings(BaseSettings): bot: BotSettings + site: SiteSettings log: LogSettings convex_url: str = Field(validation_alias=AliasChoices("CONVEX_SELF_HOSTED_URL")) diff --git a/backend/uv.lock b/backend/uv.lock index 710fcd6..fd59976 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -157,6 +157,7 @@ dependencies = [ { name = "pydantic-ai-slim", extra = ["google"] }, { name = "pydantic-settings" }, { name = "rich" }, + { name = "xkcdpass" }, ] [package.metadata] @@ -166,6 +167,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["google"], specifier = ">=1.44.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "xkcdpass", specifier = ">=1.19.0" }, ] [[package]] @@ -934,6 +936,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "xkcdpass" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/98/bdd7df66d995eab38887a8eb0afb023750b0c590eb7d8545a7b722f683ef/xkcdpass-1.30.0.tar.gz", hash = "sha256:8a3a6b60255da40d0e5c812458280278c82d2c1cb90e48afbd6777dbbf8795c3", size = 2763380, upload-time = "2026-01-11T16:09:15.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/be/ea93adc1b4597b62c236d61dc6cf0e26ca8a729cb5afae4dc5acc5b33fa8/xkcdpass-1.30.0-py3-none-any.whl", hash = "sha256:3653a4a1e13de230808bcaf11f8c04207a5d3df8e2f7e1de698e11c262b5b797", size = 2746372, upload-time = "2026-01-12T14:48:30.627Z" }, +] + [[package]] name = "yarl" version = "1.22.0" diff --git a/caddy/Caddyfile.example b/caddy/Caddyfile.example index dea968d..945d10c 100644 --- a/caddy/Caddyfile.example +++ b/caddy/Caddyfile.example @@ -13,19 +13,16 @@ } { - handle /convex* { - uri strip_prefix /convex + handle /api/check_admin_key { reverse_proxy stealth-ai-relay-convex:3210 } - handle /convex-http* { - uri strip_prefix /convex-http - reverse_proxy stealth-ai-relay-convex:3211 + handle_path /convex/* { + reverse_proxy stealth-ai-relay-convex:3210 } - handle /convex-dashboard* { - uri strip_prefix /convex-dashboard - reverse_proxy stealth-ai-relay-convex-dashboard:6791 + handle_path /convex-http/* { + reverse_proxy stealth-ai-relay-convex:3211 } handle { diff --git a/frontend/.gitignore b/frontend/.gitignore index b07e14f..f118987 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,4 +23,4 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # Convex -src/convex/_generated +src/lib/convex/_generated diff --git a/frontend/bun.lock b/frontend/bun.lock index d3aadc8..e772d16 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "convex": "^1.31.5", "convex-svelte": "^0.0.12", + "marked": "^17.0.1", + "mathjax-full": "^3.2.2", }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -283,6 +285,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.8", "", {}, "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -315,6 +319,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="], @@ -353,6 +359,8 @@ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "esm": ["esm@3.2.25", "", {}, "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], @@ -459,8 +467,16 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], + + "mathjax-full": ["mathjax-full@3.2.2", "", { "dependencies": { "esm": "^3.2.25", "mhchemparser": "^4.1.0", "mj-context-menu": "^0.6.1", "speech-rule-engine": "^4.0.6" } }, "sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w=="], + + "mhchemparser": ["mhchemparser@4.2.1", "", {}, "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "mj-context-menu": ["mj-context-menu@0.6.1", "", {}, "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -533,6 +549,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "speech-rule-engine": ["speech-rule-engine@4.1.2", "", { "dependencies": { "@xmldom/xmldom": "0.9.8", "commander": "13.1.0", "wicked-good-xpath": "1.3.0" }, "bin": { "sre": "bin/sre" } }, "sha512-S6ji+flMEga+1QU79NDbwZ8Ivf0S/MpupQQiIC0rTpU/ZTKgcajijJJb1OcByBQDjrXCN1/DJtGz4ZJeBMPGJw=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -575,6 +593,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wicked-good-xpath": ["wicked-good-xpath@1.3.0", "", {}, "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], diff --git a/frontend/convex.json b/frontend/convex.json index 89700aa..96f06f9 100644 --- a/frontend/convex.json +++ b/frontend/convex.json @@ -1,3 +1,3 @@ { - "functions": "src/convex/" + "functions": "src/lib/convex/" } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 7df665d..8e251e7 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( includeIgnoreFile(gitignorePath), + { ignores: ['**/_generated/**'] }, js.configs.recommended, ...ts.configs.recommended, ...svelte.configs.recommended, diff --git a/frontend/package.json b/frontend/package.json index 7492b6d..42c8d4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,8 @@ }, "dependencies": { "convex": "^1.31.5", - "convex-svelte": "^0.0.12" + "convex-svelte": "^0.0.12", + "marked": "^17.0.1", + "mathjax-full": "^3.2.2" } } diff --git a/frontend/src/convex/_generated/api.d.ts b/frontend/src/convex/_generated/api.d.ts new file mode 100644 index 0000000..7211fce --- /dev/null +++ b/frontend/src/convex/_generated/api.d.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server'; + +declare const fullApi: ApiFromModules<{}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi>; + +export declare const components: {}; diff --git a/frontend/src/convex/_generated/api.js b/frontend/src/convex/_generated/api.js new file mode 100644 index 0000000..24593c7 --- /dev/null +++ b/frontend/src/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from 'convex/server'; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/frontend/src/convex/_generated/dataModel.d.ts b/frontend/src/convex/_generated/dataModel.d.ts new file mode 100644 index 0000000..4fcece6 --- /dev/null +++ b/frontend/src/convex/_generated/dataModel.d.ts @@ -0,0 +1,57 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { AnyDataModel } from 'convex/server'; +import type { GenericId } from 'convex/values'; + +/** + * No `schema.ts` file found! + * + * This generated code has permissive types like `Doc = any` because + * Convex doesn't know your schema. If you'd like more type safety, see + * https://docs.convex.dev/using/schemas for instructions on how to add a + * schema file. + * + * After you change a schema, rerun codegen with `npx convex dev`. + */ + +/** + * The names of all of your Convex tables. + */ +export type TableNames = string; + +/** + * The type of a document stored in Convex. + */ +export type Doc = any; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + */ +export type Id = GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = AnyDataModel; diff --git a/frontend/src/convex/_generated/server.d.ts b/frontend/src/convex/_generated/server.d.ts new file mode 100644 index 0000000..1cc047e --- /dev/null +++ b/frontend/src/convex/_generated/server.d.ts @@ -0,0 +1,143 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter +} from 'convex/server'; +import type { DataModel } from './dataModel.js'; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/frontend/src/convex/_generated/server.js b/frontend/src/convex/_generated/server.js new file mode 100644 index 0000000..a18aa28 --- /dev/null +++ b/frontend/src/convex/_generated/server.js @@ -0,0 +1,93 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric +} from 'convex/server'; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; diff --git a/frontend/src/lib/components/ChatInput.svelte b/frontend/src/lib/components/ChatInput.svelte new file mode 100644 index 0000000..2e64560 --- /dev/null +++ b/frontend/src/lib/components/ChatInput.svelte @@ -0,0 +1,35 @@ + + +
+ + +
diff --git a/frontend/src/lib/components/ChatMessage.svelte b/frontend/src/lib/components/ChatMessage.svelte new file mode 100644 index 0000000..02ba42f --- /dev/null +++ b/frontend/src/lib/components/ChatMessage.svelte @@ -0,0 +1,47 @@ + + +
+ {#if isStreaming && !content} + + {:else} + + {@html processContent(content)} + {/if} +
diff --git a/frontend/src/lib/components/FollowUpButtons.svelte b/frontend/src/lib/components/FollowUpButtons.svelte new file mode 100644 index 0000000..7dea9af --- /dev/null +++ b/frontend/src/lib/components/FollowUpButtons.svelte @@ -0,0 +1,28 @@ + + +
+ {#each options as option (option)} + + {/each} +
diff --git a/frontend/src/lib/components/LoadingDots.svelte b/frontend/src/lib/components/LoadingDots.svelte new file mode 100644 index 0000000..227921c --- /dev/null +++ b/frontend/src/lib/components/LoadingDots.svelte @@ -0,0 +1,8 @@ + + + + + + + diff --git a/frontend/src/lib/convex/chats.ts b/frontend/src/lib/convex/chats.ts new file mode 100644 index 0000000..f08a543 --- /dev/null +++ b/frontend/src/lib/convex/chats.ts @@ -0,0 +1,98 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; + +export const getByMnemonic = query({ + args: { mnemonic: v.string() }, + returns: v.union( + v.object({ + _id: v.id('chats'), + _creationTime: v.number(), + userId: v.id('users'), + mnemonic: v.string(), + createdAt: v.number() + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db + .query('chats') + .withIndex('by_mnemonic', (q) => q.eq('mnemonic', args.mnemonic)) + .unique(); + } +}); + +export const create = mutation({ + args: { userId: v.id('users'), mnemonic: v.string() }, + returns: v.id('chats'), + handler: async (ctx, args) => { + return await ctx.db.insert('chats', { + userId: args.userId, + mnemonic: args.mnemonic, + createdAt: Date.now() + }); + } +}); + +export const clear = mutation({ + args: { chatId: v.id('chats'), preserveImages: v.optional(v.boolean()) }, + returns: v.null(), + handler: async (ctx, args) => { + const messages = await ctx.db + .query('messages') + .withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId)) + .collect(); + + for (const message of messages) { + if (args.preserveImages && message.imageStorageId) { + continue; + } + await ctx.db.delete(message._id); + } + return null; + } +}); + +export const getWithUser = query({ + args: { mnemonic: v.string() }, + returns: v.union( + v.object({ + chat: v.object({ + _id: v.id('chats'), + _creationTime: v.number(), + userId: v.id('users'), + mnemonic: v.string(), + createdAt: v.number() + }), + user: v.object({ + _id: v.id('users'), + _creationTime: v.number(), + telegramId: v.int64(), + telegramChatId: v.optional(v.int64()), + geminiApiKey: v.optional(v.string()), + systemPrompt: v.optional(v.string()), + followUpPrompt: v.optional(v.string()), + model: v.string(), + followUpModel: v.optional(v.string()), + activeChatId: v.optional(v.id('chats')) + }) + }), + v.null() + ), + handler: async (ctx, args) => { + const chat = await ctx.db + .query('chats') + .withIndex('by_mnemonic', (q) => q.eq('mnemonic', args.mnemonic)) + .unique(); + + if (!chat) { + return null; + } + + const user = await ctx.db.get(chat.userId); + if (!user) { + return null; + } + + return { chat, user }; + } +}); diff --git a/frontend/src/lib/convex/messages.ts b/frontend/src/lib/convex/messages.ts new file mode 100644 index 0000000..33e31f1 --- /dev/null +++ b/frontend/src/lib/convex/messages.ts @@ -0,0 +1,215 @@ +import { v } from 'convex/values'; +import { internalMutation, mutation, query } from './_generated/server'; + +export const listByChat = query({ + args: { chatId: v.id('chats') }, + returns: v.array( + v.object({ + _id: v.id('messages'), + _creationTime: v.number(), + chatId: v.id('chats'), + role: v.union(v.literal('user'), v.literal('assistant')), + content: v.string(), + imageStorageId: v.optional(v.id('_storage')), + imageMediaType: v.optional(v.string()), + followUpOptions: v.optional(v.array(v.string())), + source: v.union(v.literal('telegram'), v.literal('web')), + createdAt: v.number(), + isStreaming: v.optional(v.boolean()) + }) + ), + handler: async (ctx, args) => { + return await ctx.db + .query('messages') + .withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId)) + .order('asc') + .collect(); + } +}); + +export const create = mutation({ + args: { + chatId: v.id('chats'), + role: v.union(v.literal('user'), v.literal('assistant')), + content: v.string(), + source: v.union(v.literal('telegram'), v.literal('web')), + imageStorageId: v.optional(v.id('_storage')), + imageMediaType: v.optional(v.string()), + followUpOptions: v.optional(v.array(v.string())), + isStreaming: v.optional(v.boolean()) + }, + returns: v.id('messages'), + handler: async (ctx, args) => { + const messageId = await ctx.db.insert('messages', { + chatId: args.chatId, + role: args.role, + content: args.content, + source: args.source, + imageStorageId: args.imageStorageId, + imageMediaType: args.imageMediaType, + followUpOptions: args.followUpOptions, + createdAt: Date.now(), + isStreaming: args.isStreaming + }); + + if (args.source === 'web' && args.role === 'user') { + const chat = await ctx.db.get(args.chatId); + if (chat) { + await ctx.db.insert('pendingGenerations', { + userId: chat.userId, + chatId: args.chatId, + userMessage: args.content, + createdAt: Date.now() + }); + } + } + + return messageId; + } +}); + +export const update = mutation({ + args: { + messageId: v.id('messages'), + content: v.optional(v.string()), + followUpOptions: v.optional(v.array(v.string())), + isStreaming: v.optional(v.boolean()) + }, + returns: v.null(), + handler: async (ctx, args) => { + const updates: { + content?: string; + followUpOptions?: string[]; + isStreaming?: boolean; + } = {}; + + if (args.content !== undefined) { + updates.content = args.content; + } + if (args.followUpOptions !== undefined) { + updates.followUpOptions = args.followUpOptions; + } + if (args.isStreaming !== undefined) { + updates.isStreaming = args.isStreaming; + } + + await ctx.db.patch(args.messageId, updates); + return null; + } +}); + +export const getHistoryForAI = query({ + args: { chatId: v.id('chats'), limit: v.optional(v.number()) }, + returns: v.array( + v.object({ + role: v.union(v.literal('user'), v.literal('assistant')), + content: v.string() + }) + ), + handler: async (ctx, args) => { + const messages = await ctx.db + .query('messages') + .withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId)) + .order('asc') + .collect(); + + const limit = args.limit ?? 50; + const limited = messages.slice(-limit); + + return limited.map((m) => ({ + role: m.role, + content: m.content + })); + } +}); + +export const getLastAssistantMessage = query({ + args: { chatId: v.id('chats') }, + returns: v.union( + v.object({ + _id: v.id('messages'), + _creationTime: v.number(), + chatId: v.id('chats'), + role: v.union(v.literal('user'), v.literal('assistant')), + content: v.string(), + imageStorageId: v.optional(v.id('_storage')), + imageMediaType: v.optional(v.string()), + followUpOptions: v.optional(v.array(v.string())), + source: v.union(v.literal('telegram'), v.literal('web')), + createdAt: v.number(), + isStreaming: v.optional(v.boolean()) + }), + v.null() + ), + handler: async (ctx, args) => { + const messages = await ctx.db + .query('messages') + .withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId)) + .order('desc') + .collect(); + + return messages.find((m) => m.role === 'assistant') ?? null; + } +}); + +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + } +}); + +export const getImageUrls = query({ + args: { chatId: v.id('chats') }, + returns: v.array( + v.object({ + storageId: v.id('_storage'), + mediaType: v.string(), + url: v.union(v.string(), v.null()) + }) + ), + handler: async (ctx, args) => { + const messages = await ctx.db + .query('messages') + .withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId)) + .collect(); + + const imageMessages = messages.filter((m) => m.imageStorageId && m.imageMediaType); + const results = []; + + for (const msg of imageMessages) { + if (msg.imageStorageId && msg.imageMediaType) { + const url = await ctx.storage.getUrl(msg.imageStorageId); + results.push({ + storageId: msg.imageStorageId, + mediaType: msg.imageMediaType, + url + }); + } + } + + return results; + } +}); + +export const createWithImage = internalMutation({ + args: { + chatId: v.id('chats'), + content: v.string(), + imageStorageId: v.id('_storage'), + imageMediaType: v.string() + }, + returns: v.id('messages'), + handler: async (ctx, args) => { + return await ctx.db.insert('messages', { + chatId: args.chatId, + role: 'user' as const, + content: args.content, + source: 'telegram' as const, + imageStorageId: args.imageStorageId, + imageMediaType: args.imageMediaType, + createdAt: Date.now() + }); + } +}); diff --git a/frontend/src/lib/convex/pendingGenerations.ts b/frontend/src/lib/convex/pendingGenerations.ts new file mode 100644 index 0000000..47be6d9 --- /dev/null +++ b/frontend/src/lib/convex/pendingGenerations.ts @@ -0,0 +1,45 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; + +export const list = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id('pendingGenerations'), + _creationTime: v.number(), + userId: v.id('users'), + chatId: v.id('chats'), + userMessage: v.string(), + createdAt: v.number() + }) + ), + handler: async (ctx) => { + return await ctx.db.query('pendingGenerations').collect(); + } +}); + +export const create = mutation({ + args: { + userId: v.id('users'), + chatId: v.id('chats'), + userMessage: v.string() + }, + returns: v.id('pendingGenerations'), + handler: async (ctx, args) => { + return await ctx.db.insert('pendingGenerations', { + userId: args.userId, + chatId: args.chatId, + userMessage: args.userMessage, + createdAt: Date.now() + }); + } +}); + +export const remove = mutation({ + args: { id: v.id('pendingGenerations') }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.delete(args.id); + return null; + } +}); diff --git a/frontend/src/lib/convex/schema.ts b/frontend/src/lib/convex/schema.ts new file mode 100644 index 0000000..29a0a02 --- /dev/null +++ b/frontend/src/lib/convex/schema.ts @@ -0,0 +1,42 @@ +import { defineSchema, defineTable } from 'convex/server'; +import { v } from 'convex/values'; + +export default defineSchema({ + users: defineTable({ + telegramId: v.int64(), + telegramChatId: v.optional(v.int64()), + geminiApiKey: v.optional(v.string()), + systemPrompt: v.optional(v.string()), + followUpPrompt: v.optional(v.string()), + model: v.string(), + followUpModel: v.optional(v.string()), + activeChatId: v.optional(v.id('chats')) + }).index('by_telegram_id', ['telegramId']), + + chats: defineTable({ + userId: v.id('users'), + mnemonic: v.string(), + createdAt: v.number() + }).index('by_mnemonic', ['mnemonic']), + + messages: defineTable({ + chatId: v.id('chats'), + role: v.union(v.literal('user'), v.literal('assistant')), + content: v.string(), + imageStorageId: v.optional(v.id('_storage')), + imageMediaType: v.optional(v.string()), + followUpOptions: v.optional(v.array(v.string())), + source: v.union(v.literal('telegram'), v.literal('web')), + createdAt: v.number(), + isStreaming: v.optional(v.boolean()) + }) + .index('by_chat_id', ['chatId']) + .index('by_chat_id_and_created_at', ['chatId', 'createdAt']), + + pendingGenerations: defineTable({ + userId: v.id('users'), + chatId: v.id('chats'), + userMessage: v.string(), + createdAt: v.number() + }) +}); diff --git a/frontend/src/lib/convex/users.ts b/frontend/src/lib/convex/users.ts new file mode 100644 index 0000000..9dc7c31 --- /dev/null +++ b/frontend/src/lib/convex/users.ts @@ -0,0 +1,129 @@ +import { v } from 'convex/values'; +import { mutation, query } from './_generated/server'; + +const DEFAULT_MODEL = 'gemini-3-pro-preview'; + +export const getById = query({ + args: { userId: v.id('users') }, + returns: v.union( + v.object({ + _id: v.id('users'), + _creationTime: v.number(), + telegramId: v.int64(), + telegramChatId: v.optional(v.int64()), + geminiApiKey: v.optional(v.string()), + systemPrompt: v.optional(v.string()), + followUpPrompt: v.optional(v.string()), + model: v.string(), + followUpModel: v.optional(v.string()), + activeChatId: v.optional(v.id('chats')) + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db.get(args.userId); + } +}); + +export const getByTelegramId = query({ + args: { telegramId: v.int64() }, + returns: v.union( + v.object({ + _id: v.id('users'), + _creationTime: v.number(), + telegramId: v.int64(), + telegramChatId: v.optional(v.int64()), + geminiApiKey: v.optional(v.string()), + systemPrompt: v.optional(v.string()), + followUpPrompt: v.optional(v.string()), + model: v.string(), + followUpModel: v.optional(v.string()), + activeChatId: v.optional(v.id('chats')) + }), + v.null() + ), + handler: async (ctx, args) => { + return await ctx.db + .query('users') + .withIndex('by_telegram_id', (q) => q.eq('telegramId', args.telegramId)) + .unique(); + } +}); + +export const getOrCreate = mutation({ + args: { telegramId: v.int64(), telegramChatId: v.optional(v.int64()) }, + returns: v.id('users'), + handler: async (ctx, args) => { + const existing = await ctx.db + .query('users') + .withIndex('by_telegram_id', (q) => q.eq('telegramId', args.telegramId)) + .unique(); + + if (existing) { + if (args.telegramChatId && existing.telegramChatId !== args.telegramChatId) { + await ctx.db.patch(existing._id, { telegramChatId: args.telegramChatId }); + } + return existing._id; + } + + return await ctx.db.insert('users', { + telegramId: args.telegramId, + telegramChatId: args.telegramChatId, + model: DEFAULT_MODEL + }); + } +}); + +export const setApiKey = mutation({ + args: { userId: v.id('users'), apiKey: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { geminiApiKey: args.apiKey }); + return null; + } +}); + +export const setSystemPrompt = mutation({ + args: { userId: v.id('users'), prompt: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { systemPrompt: args.prompt }); + return null; + } +}); + +export const setFollowUpPrompt = mutation({ + args: { userId: v.id('users'), prompt: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { followUpPrompt: args.prompt }); + return null; + } +}); + +export const setModel = mutation({ + args: { userId: v.id('users'), model: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { model: args.model }); + return null; + } +}); + +export const setFollowUpModel = mutation({ + args: { userId: v.id('users'), model: v.string() }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { followUpModel: args.model }); + return null; + } +}); + +export const setActiveChat = mutation({ + args: { userId: v.id('users'), chatId: v.id('chats') }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { activeChatId: args.chatId }); + return null; + } +}); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0d8eb03..009474f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,8 +1,12 @@ diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index cc88df0..a0b8df2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,2 +1 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+

iykyk

diff --git a/frontend/src/routes/[mnemonic]/+page.svelte b/frontend/src/routes/[mnemonic]/+page.svelte new file mode 100644 index 0000000..fd4d1d9 --- /dev/null +++ b/frontend/src/routes/[mnemonic]/+page.svelte @@ -0,0 +1,100 @@ + + + + Chat + + + +
+ {#if chatData.isLoading} +
Loading...
+ {:else if chatData.error} +
+ Error: {chatData.error.toString()} +
+ {:else if !chatData.data} +
Chat not found
+ {:else} +
+ {#each messages as message (message._id)} + + {/each} +
+ + {#if followUpOptions.length > 0} +
+ +
+ {/if} + +
+ +
+ +
+ +
+ {/if} +
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index d4b5078..8aeca2d 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -1 +1,79 @@ @import 'tailwindcss'; + +.prose-mini h1, +.prose-mini h2, +.prose-mini h3, +.prose-mini h4 { + font-size: 12px; + font-weight: 600; + margin: 0.5em 0 0.25em; +} + +.prose-mini h1 { + font-size: 13px; +} + +.prose-mini p { + margin: 0.4em 0; +} + +.prose-mini p:first-child { + margin-top: 0; +} + +.prose-mini p:last-child { + margin-bottom: 0; +} + +.prose-mini ul, +.prose-mini ol { + margin: 0.4em 0; + padding-left: 1.2em; +} + +.prose-mini li { + margin: 0.15em 0; +} + +.prose-mini code { + font-size: 10px; + background: rgba(0, 0, 0, 0.3); + padding: 0.1em 0.3em; + border-radius: 3px; +} + +.prose-mini pre { + font-size: 10px; + background: rgba(0, 0, 0, 0.3); + padding: 0.5em; + border-radius: 4px; + overflow-x: auto; + margin: 0.4em 0; +} + +.prose-mini pre code { + background: none; + padding: 0; +} + +.prose-mini blockquote { + border-left: 2px solid rgba(255, 255, 255, 0.3); + padding-left: 0.5em; + margin: 0.4em 0; + opacity: 0.9; +} + +.prose-mini a { + text-decoration: underline; + text-underline-offset: 2px; +} + +.prose-mini strong { + font-weight: 600; +} + +.prose-mini hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.2); + margin: 0.5em 0; +} diff --git a/frontend/src/routes/service/latex/+server.ts b/frontend/src/routes/service/latex/+server.ts new file mode 100644 index 0000000..d4a15bd --- /dev/null +++ b/frontend/src/routes/service/latex/+server.ts @@ -0,0 +1,54 @@ +import type { RequestHandler } from './$types'; +import { mathjax } from 'mathjax-full/js/mathjax.js'; +import { TeX } from 'mathjax-full/js/input/tex.js'; +import { SVG } from 'mathjax-full/js/output/svg.js'; +import { liteAdaptor } from 'mathjax-full/js/adaptors/liteAdaptor.js'; +import { RegisterHTMLHandler } from 'mathjax-full/js/handlers/html.js'; +import { AllPackages } from 'mathjax-full/js/input/tex/AllPackages.js'; + +const adaptor = liteAdaptor(); +RegisterHTMLHandler(adaptor); + +const tex = new TeX({ packages: AllPackages }); +const svg = new SVG({ fontCache: 'none' }); +const html = mathjax.document('', { InputJax: tex, OutputJax: svg }); + +const cache = new Map(); + +export const GET: RequestHandler = async ({ url }) => { + const texInput = url.searchParams.get('tex'); + + if (!texInput) { + return new Response('Missing tex parameter', { status: 400 }); + } + + const cached = cache.get(texInput); + if (cached) { + return new Response(cached, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=31536000, immutable' + } + }); + } + + try { + const node = html.convert(texInput, { display: false }); + const svgString = adaptor.innerHTML(node).replace('style="', 'style="color: white; '); + + if (cache.size > 1000) { + const firstKey = cache.keys().next().value; + if (firstKey) cache.delete(firstKey); + } + cache.set(texInput, svgString); + + return new Response(svgString, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=31536000, immutable' + } + }); + } catch (e) { + return new Response(`Error rendering LaTeX: ${e}`, { status: 500 }); + } +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 56f40c7..2d35c4f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,4 +2,6 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; -export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});