feat(*): first mvp

This commit is contained in:
h
2026-01-20 21:54:48 +01:00
parent b9703da2fc
commit ec17f5e0fd
52 changed files with 2599 additions and 576 deletions

View File

@@ -15,3 +15,24 @@ repos:
entry: uvx ty check entry: uvx ty check
language: python language: python
types_or: [ python, pyi ] 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

840
CLAUDE.md
View File

@@ -1,36 +1,166 @@
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
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import {PUBLIC_CONVEX_URL} from '$env/static/public';
import {setupConvex} from 'convex-svelte';
const {children} = $props();
setupConvex(PUBLIC_CONVEX_URL);
</script>
{@render children()}
```
### Using Convex queries/mutations
```sveltehtml
<script lang="ts">
import {useQuery, useMutation} from 'convex-svelte';
import {api} from '$lib/convex/_generated/api.js';
const tasks = useQuery(api.tasks.list, {});
const createTask = useMutation(api.tasks.create);
</script>
{#if tasks.isLoading}
Loading...
{:else if tasks.error}
Error: {tasks.error.toString()}
{:else}
{#each tasks.data as task}
<div>{task.text}</div>
{/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 # 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: ## Available MCP Tools:
### 1. list-sections ### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. Use this FIRST to discover all available documentation sections. Returns a structured
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. 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 ### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections. Retrieves full documentation content for specific sections. Accepts single or multiple
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. 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 ### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions. 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 ### 4. playground-link
Generates a Svelte Playground link with the provided code. 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 # Convex guidelines
## Function guidelines ## Function guidelines
### New function syntax ### New function syntax
- ALWAYS use the new function syntax for Convex functions. For example: - ALWAYS use the new function syntax for Convex functions. For example:
```typescript ```typescript
import { query } from "./_generated/server"; import {query} from "./_generated/server";
import { v } from "convex/values"; import {v} from "convex/values";
export const f = query({ export const f = query({
args: {}, args: {},
returns: v.null(), returns: v.null(),
@@ -41,41 +171,53 @@ export const f = query({
``` ```
### Http endpoint syntax ### 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 ```typescript
import { httpRouter } from "convex/server"; import {httpRouter} from "convex/server";
import { httpAction } from "./_generated/server"; import {httpAction} from "./_generated/server";
const http = httpRouter(); const http = httpRouter();
http.route({ http.route({
path: "/echo", path: "/echo",
method: "POST", method: "POST",
handler: httpAction(async (ctx, req) => { handler: httpAction(async (ctx, req) => {
const body = await req.bytes(); 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 ### Validators
- Below is an example of an array validator: - Below is an example of an array validator:
```typescript ```typescript
import { mutation } from "./_generated/server"; import {mutation} from "./_generated/server";
import { v } from "convex/values"; import {v} from "convex/values";
export default mutation({ export default mutation({
args: { args: {
simpleArray: v.array(v.union(v.string(), v.number())), 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 ```typescript
import { defineSchema, defineTable } from "convex/server"; import {defineSchema, defineTable} from "convex/server";
import { v } from "convex/values"; import {v} from "convex/values";
export default defineSchema({ export default defineSchema({
results: defineTable( results: defineTable(
@@ -92,10 +234,13 @@ export default defineSchema({
) )
}); });
``` ```
- 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 ```typescript
import { query } from "./_generated/server"; import {query} from "./_generated/server";
import { v } from "convex/values"; import {v} from "convex/values";
export const exampleQuery = query({ export const exampleQuery = query({
args: {}, args: {},
@@ -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 "_". | | 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 ### 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. - 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. - ALWAYS include argument and return validators for all Convex functions. This includes
- If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`. 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 ### Function calling
- Use `ctx.runQuery` to call a query from a query, mutation, or action. - 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.runMutation` to call a mutation from a mutation or action.
- Use `ctx.runAction` to call an action from an 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. - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to
- 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. Node). Otherwise, pull out the shared code into a helper async function and call that
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. directly instead.
- 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, - 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({ export const f = query({
args: { name: v.string() }, args: { name: v.string() },
@@ -155,28 +320,41 @@ export const g = query({
``` ```
### Function references ### Function references
- Function references are pointers to registered Convex functions. - 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 `api` object defined by the framework in `convex/_generated/api.ts` to call
- 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`. public functions registered with `query`, `mutation`, or `action`.
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. call internal (or private) functions registered with `internalQuery`,
- 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`. `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 ### 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 `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 ### Pagination
- Paginated queries are queries that return a list of results in incremental pages. - Paginated queries are queries that return a list of results in incremental pages.
- You can define pagination using the following syntax: - You can define pagination using the following syntax:
```ts ```ts
import { v } from "convex/values"; import {v} from "convex/values";
import { query, mutation } from "./_generated/server"; import {query, mutation} from "./_generated/server";
import { paginationOptsValidator } from "convex/server"; import {paginationOptsValidator} from "convex/server";
export const listWithExtraArg = query({ export const listWithExtraArg = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() }, args: {paginationOpts: paginationOptsValidator, author: v.string()},
handler: async (ctx, args) => { handler: async (ctx, args) => {
return await ctx.db return await ctx.db
.query("messages") .query("messages")
@@ -186,35 +364,58 @@ export const listWithExtraArg = query({
}, },
}); });
``` ```
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 ## 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 ## Schema guidelines
- Always define your schema in `convex/schema.ts`. - Always define your schema in `convex/schema.ts`.
- Always import the schema definition functions from `convex/server`. - 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)`. - System fields are automatically added to all documents and are prefixed with an
- 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". underscore. The two system fields that are automatically added to all documents are
- 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. `_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 ## 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<Id<'users'>, 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<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type
in a query:
```ts ```ts
import { query } from "./_generated/server"; import {query} from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel"; import {Doc, Id} from "./_generated/dataModel";
export const exampleQuery = query({ export const exampleQuery = query({
args: { userIds: v.array(v.id("users")) }, args: {userIds: v.array(v.id("users"))},
returns: v.record(v.id("users"), v.string()), returns: v.record(v.id("users"), v.string()),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const idToUsername: Record<Id<"users">, string> = {}; const idToUsername: Record<Id<"users">, string> = {};
@@ -229,14 +430,22 @@ export const exampleQuery = query({
}, },
}); });
``` ```
- 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. - 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<T> = [...];` - When using the `Array` type, make sure to always define your arrays as
- When using the `Record` type, make sure to always define your records as `const record: Record<KeyType, ValueType> = {...};` `const array: Array<T> = [...];`
- Always add `@types/node` to your `package.json` when using any Node.js built-in modules. - When using the `Record` type, make sure to always define your records as
`const record: Record<KeyType, ValueType> = {...};`
- Always add `@types/node` to your `package.json` when using any Node.js built-in
modules.
## Full text search guidelines ## 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 const messages = await ctx.db
.query("messages") .query("messages")
@@ -246,26 +455,42 @@ q.search("body", "hello hi").eq("channel", "#general"),
.take(10); .take(10);
## Query guidelines ## 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 ## 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 ## 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. - 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: - Below is an example of the syntax for an action:
```ts ```ts
import { action } from "./_generated/server"; import {action} from "./_generated/server";
export const exampleAction = action({ export const exampleAction = action({
args: {}, args: {},
@@ -278,14 +503,20 @@ export const exampleAction = action({
``` ```
## Scheduling guidelines ## Scheduling guidelines
### Cron 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. - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, 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 ```ts
import { cronJobs } from "convex/server"; import {cronJobs} from "convex/server";
import { internal } from "./_generated/api"; import {internal} from "./_generated/api";
import { internalAction } from "./_generated/server"; import {internalAction} from "./_generated/server";
const empty = internalAction({ const empty = internalAction({
args: {}, args: {},
@@ -298,20 +529,25 @@ const empty = internalAction({
const crons = cronJobs(); const crons = cronJobs();
// Run `internal.crons.empty` every two hours. // 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; 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 ## File storage guidelines
- Convex includes file storage for large files like images, videos, and PDFs. - 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. - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. `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">`. 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 { query } from "./_generated/server";
import { Id } from "./_generated/dataModel"; 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.
- Convex storage stores items as `Blob` objects. You must convert all items to/from a
# Examples: `Blob` when using Convex storage.
## 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: <none>
- channels
- validator: { name: v.string() }
- indexes: <none>
- 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
<script lang="ts">
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte';
const { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
</script>
{@render children()}
```
#### src/routes/+page.svelte
```sveltehtml
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '../convex/_generated/api.js';
const query = useQuery(api.tasks.get, {});
</script>
{#if query.isLoading}
Loading...
{:else if query.error}
failed to load: {query.error.toString()}
{:else}
<ul>
{#each query.data as task}
<li>
{task.isCompleted ? '☑' : '☐'}
<span>{task.text}</span>
<span>assigned by {task.assigner}</span>
</li>
{/each}
</ul>
{/if}
```

View File

@@ -1,5 +1,7 @@
BOT__TOKEN=<BOT__TOKEN> BOT__TOKEN=<BOT__TOKEN>
SITE__URL=<SITE__URL>
LOG__LEVEL=INFO LOG__LEVEL=INFO
LOG__LEVEL_EXTERNAL=WARNING LOG__LEVEL_EXTERNAL=WARNING
LOG__SHOW_TIME=false LOG__SHOW_TIME=false

View File

@@ -12,6 +12,7 @@ dependencies = [
"pydantic-ai-slim[google]>=1.44.0", "pydantic-ai-slim[google]>=1.44.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"rich>=14.2.0", "rich>=14.2.0",
"xkcdpass>=1.19.0",
] ]
[build-system] [build-system]

View File

@@ -11,11 +11,20 @@ setup_logging()
async def runner() -> None: async def runner() -> None:
from . import handlers # noqa: PLC0415 from . import handlers # noqa: PLC0415
from .common import bot, dp # noqa: PLC0415 from .common import bot, dp # noqa: PLC0415
from .sync import start_sync_listener # noqa: PLC0415
dp.include_routers(handlers.router) dp.include_routers(handlers.router)
sync_task = asyncio.create_task(start_sync_listener(bot))
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
try:
await dp.start_polling(bot) await dp.start_polling(bot)
finally:
sync_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await sync_task
def plugins() -> None: def plugins() -> None:

View File

@@ -1,7 +1,9 @@
from aiogram import Router from aiogram import Router
from . import initialize, start from . import apikey, chat, initialize, message, start
router = Router() router = Router()
router.include_routers(start.router, initialize.router) router.include_routers(
start.router, initialize.router, apikey.router, chat.router, message.router
)

View File

@@ -0,0 +1,3 @@
from .handler import router
__all__ = ["router"]

View File

@@ -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.")

View File

@@ -0,0 +1,3 @@
from .handlers import router
__all__ = ["router"]

View File

@@ -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<code>{url}</code>", 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 = ["<b>Available presets:</b>\n"]
lines.extend(f"• <code>/preset {name}</code>" 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.")

View File

@@ -8,7 +8,16 @@ router = Router()
@router.startup() @router.startup()
async def startup(bot: Bot) -> None: async def startup(bot: Bot) -> None:
await bot.set_my_commands( 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}") logger.info(f"[green]Started as[/] @{(await bot.me()).username}")

View File

@@ -0,0 +1,3 @@
from .handler import router
__all__ = ["router"]

View File

@@ -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
)

View File

@@ -3,7 +3,23 @@ from aiogram.filters import CommandStart
router = Router() router = Router()
WELCOME_MESSAGE = """
<b>Welcome to AI Chat!</b>
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()) @router.message(CommandStart())
async def on_start(message: types.Message) -> None: async def on_start(message: types.Message) -> None:
await message.answer("hi") await message.answer(WELCOME_MESSAGE, parse_mode="HTML")

View File

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -0,0 +1,10 @@
from typing import TypedDict
class AIResponse(TypedDict):
answer: str
follow_up_options: list[str]
class FollowUpOptions(TypedDict):
options: list[str]

View File

@@ -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)}

View File

@@ -0,0 +1,3 @@
from .generator import generate_mnemonic
__all__ = ["generate_mnemonic"]

View File

@@ -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)

58
backend/src/bot/sync.py Normal file
View File

@@ -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})

View File

@@ -0,0 +1,3 @@
from .client import ConvexClient
__all__ = ["ConvexClient"]

View File

@@ -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 {})

View File

@@ -6,6 +6,10 @@ class BotSettings(BaseSettings):
token: SecretStr token: SecretStr
class SiteSettings(BaseSettings):
url: str = Field(default="https://localhost")
class LogSettings(BaseSettings): class LogSettings(BaseSettings):
level: str = "INFO" level: str = "INFO"
level_external: str = "WARNING" level_external: str = "WARNING"
@@ -15,6 +19,7 @@ class LogSettings(BaseSettings):
class Settings(BaseSettings): class Settings(BaseSettings):
bot: BotSettings bot: BotSettings
site: SiteSettings
log: LogSettings log: LogSettings
convex_url: str = Field(validation_alias=AliasChoices("CONVEX_SELF_HOSTED_URL")) convex_url: str = Field(validation_alias=AliasChoices("CONVEX_SELF_HOSTED_URL"))

11
backend/uv.lock generated
View File

@@ -157,6 +157,7 @@ dependencies = [
{ name = "pydantic-ai-slim", extra = ["google"] }, { name = "pydantic-ai-slim", extra = ["google"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "rich" }, { name = "rich" },
{ name = "xkcdpass" },
] ]
[package.metadata] [package.metadata]
@@ -166,6 +167,7 @@ requires-dist = [
{ name = "pydantic-ai-slim", extras = ["google"], specifier = ">=1.44.0" }, { name = "pydantic-ai-slim", extras = ["google"], specifier = ">=1.44.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "rich", specifier = ">=14.2.0" }, { name = "rich", specifier = ">=14.2.0" },
{ name = "xkcdpass", specifier = ">=1.19.0" },
] ]
[[package]] [[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" }, { 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]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.22.0"

View File

@@ -13,19 +13,16 @@
} }
<DOMAIN> { <DOMAIN> {
handle /convex* { handle /api/check_admin_key {
uri strip_prefix /convex
reverse_proxy stealth-ai-relay-convex:3210 reverse_proxy stealth-ai-relay-convex:3210
} }
handle /convex-http* { handle_path /convex/* {
uri strip_prefix /convex-http reverse_proxy stealth-ai-relay-convex:3210
reverse_proxy stealth-ai-relay-convex:3211
} }
handle /convex-dashboard* { handle_path /convex-http/* {
uri strip_prefix /convex-dashboard reverse_proxy stealth-ai-relay-convex:3211
reverse_proxy stealth-ai-relay-convex-dashboard:6791
} }
handle { handle {

2
frontend/.gitignore vendored
View File

@@ -23,4 +23,4 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Convex # Convex
src/convex/_generated src/lib/convex/_generated

View File

@@ -7,6 +7,8 @@
"dependencies": { "dependencies": {
"convex": "^1.31.5", "convex": "^1.31.5",
"convex-svelte": "^0.0.12", "convex-svelte": "^0.0.12",
"marked": "^17.0.1",
"mathjax-full": "^3.2.2",
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.0", "@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=="], "@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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],

View File

@@ -1,3 +1,3 @@
{ {
"functions": "src/convex/" "functions": "src/lib/convex/"
} }

View File

@@ -12,6 +12,7 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig( export default defineConfig(
includeIgnoreFile(gitignorePath), includeIgnoreFile(gitignorePath),
{ ignores: ['**/_generated/**'] },
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs.recommended, ...svelte.configs.recommended,

View File

@@ -37,6 +37,8 @@
}, },
"dependencies": { "dependencies": {
"convex": "^1.31.5", "convex": "^1.31.5",
"convex-svelte": "^0.0.12" "convex-svelte": "^0.0.12",
"marked": "^17.0.1",
"mathjax-full": "^3.2.2"
} }
} }

35
frontend/src/convex/_generated/api.d.ts vendored Normal file
View File

@@ -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<typeof fullApi, FunctionReference<any, 'public'>>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<typeof fullApi, FunctionReference<any, 'internal'>>;
export declare const components: {};

View File

@@ -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();

View File

@@ -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<TableName extends TableNames = TableNames> = GenericId<TableName>;
/**
* 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;

View File

@@ -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<DataModel, 'public'>;
/**
* 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<DataModel, 'internal'>;
/**
* 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<DataModel, 'public'>;
/**
* 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<DataModel, 'internal'>;
/**
* 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<DataModel, 'public'>;
/**
* 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<DataModel, 'internal'>;
/**
* 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<DataModel>;
/**
* 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<DataModel>;
/**
* 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<DataModel>;
/**
* 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<DataModel>;
/**
* 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<DataModel>;

View File

@@ -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;

View File

@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
onsubmit: (message: string) => void;
disabled?: boolean;
}
let { onsubmit, disabled = false }: Props = $props();
let value = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
const trimmed = value.trim();
if (trimmed && !disabled) {
onsubmit(trimmed);
value = '';
}
}
</script>
<form onsubmit={handleSubmit} class="flex gap-2">
<input
type="text"
bind:value
{disabled}
placeholder="Message..."
class="flex-1 rounded-lg bg-neutral-800 px-3 py-2 text-[11px] text-white placeholder-neutral-500 outline-none focus:ring-1 focus:ring-neutral-600"
/>
<button
type="submit"
{disabled}
class="rounded-lg bg-blue-600 px-3 py-2 text-[11px] text-white transition-colors hover:bg-blue-500 disabled:opacity-50"
>
Send
</button>
</form>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { Marked } from 'marked';
import LoadingDots from './LoadingDots.svelte';
interface Props {
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
}
let { role, content, isStreaming = false }: Props = $props();
const marked = new Marked({
breaks: true,
gfm: true
});
function processLatex(text: string): string {
return text
.replace(/\$\$(.*?)\$\$/gs, (_, tex) => {
const encoded = encodeURIComponent(tex.trim());
return `<img src="/service/latex?tex=${encoded}&display=1" alt="LaTeX" class="block my-1 max-h-12" />`;
})
.replace(/\$(.+?)\$/g, (_, tex) => {
const encoded = encodeURIComponent(tex.trim());
return `<img src="/service/latex?tex=${encoded}" alt="LaTeX" class="inline-block align-middle max-h-4" />`;
});
}
function processContent(text: string): string {
const withLatex = processLatex(text);
return marked.parse(withLatex) as string;
}
</script>
<div
class="prose-mini w-full rounded-lg px-2.5 py-1.5 text-[11px] leading-relaxed {role === 'user'
? 'bg-blue-600 text-white'
: 'bg-neutral-800 text-neutral-100'}"
>
{#if isStreaming && !content}
<LoadingDots />
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html processContent(content)}
{/if}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
options: string[];
onselect: (option: string) => void;
}
let { options, onselect }: Props = $props();
function processLatex(text: string): string {
return text.replace(/\$(.+?)\$/g, (_, tex) => {
const encoded = encodeURIComponent(tex.trim());
return `<img src="/service/latex?tex=${encoded}" alt="LaTeX" class="inline-block align-middle max-h-3" />`;
});
}
</script>
<div class="flex flex-wrap gap-1.5">
{#each options as option (option)}
<button
type="button"
onclick={() => onselect(option)}
class="rounded-full bg-neutral-800 px-2.5 py-1 text-[10px] text-neutral-200 transition-colors hover:bg-neutral-700 active:bg-neutral-600"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html processLatex(option)}
</button>
{/each}
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
</script>
<span class="inline-flex gap-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]"></span>
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-current"></span>
</span>

View File

@@ -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 };
}
});

View File

@@ -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()
});
}
});

View File

@@ -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;
}
});

View File

@@ -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()
})
});

View File

@@ -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;
}
});

View File

@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte';
let { children } = $props(); let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>

View File

@@ -1,2 +1 @@
<h1>Welcome to SvelteKit</h1> <h1>iykyk</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { page } from '$app/state';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '$lib/convex/_generated/api';
import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatInput from '$lib/components/ChatInput.svelte';
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
let mnemonic = $derived(page.params.mnemonic);
const client = useConvexClient();
const chatData = useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
const messagesQuery = useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
let messages = $derived(messagesQuery.data ?? []);
let lastMessage = $derived(messages[messages.length - 1]);
let followUpOptions = $derived(
lastMessage?.role === 'assistant' && lastMessage.followUpOptions
? lastMessage.followUpOptions
: []
);
$effect(() => {
if (messages.length) {
window.scrollTo(0, document.body.scrollHeight);
}
});
async function sendMessage(content: string) {
const chat = chatData.data?.chat;
if (!chat) return;
await client.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
}
async function summarize() {
const chat = chatData.data?.chat;
if (!chat) return;
await client.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
}
</script>
<svelte:head>
<title>Chat</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<div class="min-h-dvh bg-black text-white">
{#if chatData.isLoading}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Loading...</div>
{:else if chatData.error}
<div class="flex min-h-dvh items-center justify-center text-red-500">
Error: {chatData.error.toString()}
</div>
{:else if !chatData.data}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Chat not found</div>
{:else}
<div class="space-y-1.5 p-2">
{#each messages as message (message._id)}
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
{/each}
</div>
{#if followUpOptions.length > 0}
<div class="border-t border-neutral-800 px-2 py-1.5">
<FollowUpButtons options={followUpOptions} onselect={sendMessage} />
</div>
{/if}
<div class="border-t border-neutral-800 px-2 pt-1.5">
<button
onclick={summarize}
class="rounded bg-neutral-800 px-2 py-1 text-[10px] text-neutral-400"
>
/summarize
</button>
</div>
<div class="p-2 pt-1">
<ChatInput onsubmit={sendMessage} />
</div>
{/if}
</div>

View File

@@ -1 +1,79 @@
@import 'tailwindcss'; @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;
}

View File

@@ -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<string, string>();
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 });
}
};

View File

@@ -2,4 +2,6 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});