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

2
frontend/.gitignore vendored
View File

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

View File

@@ -7,6 +7,8 @@
"dependencies": {
"convex": "^1.31.5",
"convex-svelte": "^0.0.12",
"marked": "^17.0.1",
"mathjax-full": "^3.2.2",
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
@@ -283,6 +285,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.53.1", "", { "dependencies": { "@typescript-eslint/types": "8.53.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.9.8", "", {}, "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -315,6 +319,8 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="],
@@ -353,6 +359,8 @@
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"esm": ["esm@3.2.25", "", {}, "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
@@ -459,8 +467,16 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"mathjax-full": ["mathjax-full@3.2.2", "", { "dependencies": { "esm": "^3.2.25", "mhchemparser": "^4.1.0", "mj-context-menu": "^0.6.1", "speech-rule-engine": "^4.0.6" } }, "sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w=="],
"mhchemparser": ["mhchemparser@4.2.1", "", {}, "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"mj-context-menu": ["mj-context-menu@0.6.1", "", {}, "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -533,6 +549,8 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"speech-rule-engine": ["speech-rule-engine@4.1.2", "", { "dependencies": { "@xmldom/xmldom": "0.9.8", "commander": "13.1.0", "wicked-good-xpath": "1.3.0" }, "bin": { "sre": "bin/sre" } }, "sha512-S6ji+flMEga+1QU79NDbwZ8Ivf0S/MpupQQiIC0rTpU/ZTKgcajijJJb1OcByBQDjrXCN1/DJtGz4ZJeBMPGJw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -575,6 +593,8 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wicked-good-xpath": ["wicked-good-xpath@1.3.0", "", {}, "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],

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(
includeIgnoreFile(gitignorePath),
{ ignores: ['**/_generated/**'] },
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,

View File

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

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">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte';
let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>

View File

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

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';
.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 { defineConfig } from 'vite';
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});