feat(*): first mvp
This commit is contained in:
35
frontend/src/convex/_generated/api.d.ts
vendored
Normal file
35
frontend/src/convex/_generated/api.d.ts
vendored
Normal 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: {};
|
||||
23
frontend/src/convex/_generated/api.js
Normal file
23
frontend/src/convex/_generated/api.js
Normal 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();
|
||||
57
frontend/src/convex/_generated/dataModel.d.ts
vendored
Normal file
57
frontend/src/convex/_generated/dataModel.d.ts
vendored
Normal 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;
|
||||
143
frontend/src/convex/_generated/server.d.ts
vendored
Normal file
143
frontend/src/convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
93
frontend/src/convex/_generated/server.js
Normal file
93
frontend/src/convex/_generated/server.js
Normal 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;
|
||||
35
frontend/src/lib/components/ChatInput.svelte
Normal file
35
frontend/src/lib/components/ChatInput.svelte
Normal 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>
|
||||
47
frontend/src/lib/components/ChatMessage.svelte
Normal file
47
frontend/src/lib/components/ChatMessage.svelte
Normal 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>
|
||||
28
frontend/src/lib/components/FollowUpButtons.svelte
Normal file
28
frontend/src/lib/components/FollowUpButtons.svelte
Normal 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>
|
||||
8
frontend/src/lib/components/LoadingDots.svelte
Normal file
8
frontend/src/lib/components/LoadingDots.svelte
Normal 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>
|
||||
98
frontend/src/lib/convex/chats.ts
Normal file
98
frontend/src/lib/convex/chats.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
215
frontend/src/lib/convex/messages.ts
Normal file
215
frontend/src/lib/convex/messages.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
});
|
||||
45
frontend/src/lib/convex/pendingGenerations.ts
Normal file
45
frontend/src/lib/convex/pendingGenerations.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
42
frontend/src/lib/convex/schema.ts
Normal file
42
frontend/src/lib/convex/schema.ts
Normal 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()
|
||||
})
|
||||
});
|
||||
129
frontend/src/lib/convex/users.ts
Normal file
129
frontend/src/lib/convex/users.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
100
frontend/src/routes/[mnemonic]/+page.svelte
Normal file
100
frontend/src/routes/[mnemonic]/+page.svelte
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
54
frontend/src/routes/service/latex/+server.ts
Normal file
54
frontend/src/routes/service/latex/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user