Compare commits

...

26 Commits

Author SHA1 Message Date
h
14617cba84 feat(frontend): POST for images 2026-01-21 10:38:57 +01:00
h
8b38e04039 feat(frontend): POST for images 2026-01-21 10:25:16 +01:00
h
7e3d80b832 feat(frontend): POST for images 2026-01-21 10:20:17 +01:00
h
5441454993 fix(bot): better buttons 2026-01-21 03:00:22 +01:00
h
5af751f575 fix(bot): better buttons 2026-01-21 02:58:27 +01:00
h
c32de547bf fix(bot): better buttons 2026-01-21 02:53:22 +01:00
h
058ec809ff fix(bot): better buttons 2026-01-21 02:51:59 +01:00
h
e1e0670d0e fix(bot): better prompt preset 2026-01-21 02:49:24 +01:00
h
ab073397ba fix(bot): better prompt preset 2026-01-21 02:46:57 +01:00
h
2e5ffa76da fix(frontend): scroll 2026-01-21 02:42:15 +01:00
h
a864c8b662 feat(infra): migrate 2026-01-21 02:38:52 +01:00
h
35b58bac06 fix(frontend): better layout 2026-01-21 02:35:18 +01:00
h
ae9013536b fix(*): images do work 2026-01-21 02:33:01 +01:00
h
592aa5bc6b fix(frontend): ws replacement 2026-01-21 02:16:35 +01:00
h
9d579d9b9f fix(frontend): ws replacement 2026-01-21 02:07:44 +01:00
h
4cb1585f53 fix(frontend): ws replacement 2026-01-21 02:04:44 +01:00
h
5a6b4ebacd fix(frontend): ws replacement 2026-01-21 02:03:36 +01:00
h
277e68f1ed fix(frontend): not building 2026-01-21 01:48:10 +01:00
h
9869ac2f20 fix(frontend): not building 2026-01-21 01:45:59 +01:00
h
cd2a0f700f fix(frontend): not building 2026-01-21 01:45:45 +01:00
h
2978be0491 fix(frontend): not building 2026-01-21 01:44:07 +01:00
h
4ebc1db5e6 fix(frontend): not building 2026-01-21 01:39:57 +01:00
h
4b72ab7511 fix(frontend): not building 2026-01-21 01:39:51 +01:00
h
1969af367c fix(frontend): not building 2026-01-21 01:36:05 +01:00
h
20478eb192 fix(frontend): not building 2026-01-21 01:35:35 +01:00
h
310a7b2545 fix(frontend): not building 2026-01-21 01:31:01 +01:00
26 changed files with 548 additions and 218 deletions

View File

@@ -22,7 +22,7 @@ rebuild:
docker compose --profile services up -d docker compose --profile services up -d
migrate: migrate:
docker compose --profile migrate run --rm migrator $(filter-out $@,$(MAKECMDGOALS)) docker compose run --rm migrate
convex-key: convex-key:
docker compose exec convex ./generate_admin_key.sh docker compose exec convex ./generate_admin_key.sh

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import base64
import contextlib import contextlib
import io import io
import time import time
@@ -26,6 +27,14 @@ EDIT_THROTTLE_SECONDS = 1.0
TELEGRAM_MAX_LENGTH = 4096 TELEGRAM_MAX_LENGTH = 4096
async def fetch_chat_images(chat_id: str) -> list[ImageData]:
chat_images = await convex.query("messages:getChatImages", {"chatId": chat_id})
return [
ImageData(data=base64.b64decode(img["base64"]), media_type=img["mediaType"])
for img in (chat_images or [])
]
def make_follow_up_keyboard(options: list[str]) -> ReplyKeyboardMarkup: def make_follow_up_keyboard(options: list[str]) -> ReplyKeyboardMarkup:
buttons = [[KeyboardButton(text=opt)] for opt in options] buttons = [[KeyboardButton(text=opt)] for opt in options]
return ReplyKeyboardMarkup( return ReplyKeyboardMarkup(
@@ -114,7 +123,7 @@ async def send_long_message(
) )
async def process_message_from_web( # noqa: C901, PLR0915 async def process_message_from_web( # noqa: C901, PLR0912, PLR0915
convex_user_id: str, text: str, bot: Bot, convex_chat_id: str convex_user_id: str, text: str, bot: Bot, convex_chat_id: str
) -> None: ) -> None:
user = await convex.query("users:getById", {"userId": convex_user_id}) user = await convex.query("users:getById", {"userId": convex_user_id})
@@ -178,7 +187,11 @@ async def process_message_from_web( # noqa: C901, PLR0915
prompt_text = text prompt_text = text
hist = history[:-1] hist = history[:-1]
final_answer = await stream_response(text_agent, prompt_text, hist, on_chunk) chat_images = await fetch_chat_images(convex_chat_id)
final_answer = await stream_response(
text_agent, prompt_text, hist, on_chunk, images=chat_images
)
if state: if state:
await state.flush() await state.flush()
@@ -189,7 +202,7 @@ async def process_message_from_web( # noqa: C901, PLR0915
follow_up_agent = create_follow_up_agent( follow_up_agent = create_follow_up_agent(
api_key=api_key, model_name=follow_up_model, system_prompt=follow_up_prompt 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) follow_ups = await get_follow_ups(follow_up_agent, full_history, chat_images)
if state: if state:
await state.stop_typing() await state.stop_typing()
@@ -204,6 +217,21 @@ async def process_message_from_web( # noqa: C901, PLR0915
}, },
) )
if is_summarize:
await convex.mutation(
"chats:clear", {"chatId": convex_chat_id, "preserveImages": True}
)
await convex.mutation(
"messages:create",
{
"chatId": convex_chat_id,
"role": "assistant",
"content": final_answer,
"source": "web",
"followUpOptions": follow_ups,
},
)
if tg_chat_id and processing_msg: if tg_chat_id and processing_msg:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await processing_msg.delete() await processing_msg.delete()
@@ -229,7 +257,7 @@ async def process_message_from_web( # noqa: C901, PLR0915
async def process_message( async def process_message(
user_id: int, text: str, bot: Bot, chat_id: int, image: ImageData | None = None user_id: int, text: str, bot: Bot, chat_id: int, *, skip_user_message: bool = False
) -> None: ) -> None:
user = await convex.query( user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(user_id)} "users:getByTelegramId", {"telegramId": ConvexInt64(user_id)}
@@ -251,15 +279,16 @@ async def process_message(
api_key = user["geminiApiKey"] api_key = user["geminiApiKey"]
model_name = user.get("model", "gemini-3-pro-preview") model_name = user.get("model", "gemini-3-pro-preview")
await convex.mutation( if not skip_user_message:
"messages:create", await convex.mutation(
{ "messages:create",
"chatId": active_chat_id, {
"role": "user", "chatId": active_chat_id,
"content": text, "role": "user",
"source": "telegram", "content": text,
}, "source": "telegram",
) },
)
assistant_message_id = await convex.mutation( assistant_message_id = await convex.mutation(
"messages:create", "messages:create",
@@ -293,8 +322,10 @@ async def process_message(
{"messageId": assistant_message_id, "content": content}, {"messageId": assistant_message_id, "content": content},
) )
chat_images = await fetch_chat_images(active_chat_id)
final_answer = await stream_response( final_answer = await stream_response(
text_agent, text, history[:-2], on_chunk, image=image text_agent, text, history[:-2], on_chunk, images=chat_images
) )
await state.flush() await state.flush()
@@ -305,7 +336,7 @@ async def process_message(
follow_up_agent = create_follow_up_agent( follow_up_agent = create_follow_up_agent(
api_key=api_key, model_name=follow_up_model, system_prompt=follow_up_prompt 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) follow_ups = await get_follow_ups(follow_up_agent, full_history, chat_images)
await state.stop_typing() await state.stop_typing()
@@ -380,6 +411,14 @@ async def on_photo_message(message: types.Message, bot: Bot) -> None:
}, },
) )
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)}
)
if not user or not user.get("activeChatId"):
await message.answer("Use /new first to create a chat.")
return
caption = message.caption or "Process the image according to your task" caption = message.caption or "Process the image according to your task"
photo = message.photo[-1] photo = message.photo[-1]
@@ -391,11 +430,24 @@ async def on_photo_message(message: types.Message, bot: Bot) -> None:
buffer = io.BytesIO() buffer = io.BytesIO()
await bot.download_file(file.file_path, buffer) await bot.download_file(file.file_path, buffer)
image_bytes = buffer.getvalue() image_bytes = buffer.getvalue()
image_base64 = base64.b64encode(image_bytes).decode()
ext = file.file_path.rsplit(".", 1)[-1].lower() ext = file.file_path.rsplit(".", 1)[-1].lower()
media_type = f"image/{ext}" if ext in ("png", "gif", "webp") else "image/jpeg" media_type = f"image/{ext}" if ext in ("png", "gif", "webp") else "image/jpeg"
image = ImageData(data=image_bytes, media_type=media_type)
active_chat_id = user["activeChatId"]
await convex.mutation(
"messages:create",
{
"chatId": active_chat_id,
"role": "user",
"content": caption,
"source": "telegram",
"imageBase64": image_base64,
"imageMediaType": media_type,
},
)
await process_message( await process_message(
message.from_user.id, caption, bot, message.chat.id, image=image message.from_user.id, caption, bot, message.chat.id, skip_user_message=True
) )

View File

@@ -41,7 +41,7 @@ def create_text_agent(
model = GoogleModel(model_name, provider=provider) model = GoogleModel(model_name, provider=provider)
base_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT base_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
full_prompt = f"{base_prompt} {LATEX_INSTRUCTION}" full_prompt = f"{base_prompt} {LATEX_INSTRUCTION}"
return Agent(model, system_prompt=full_prompt) return Agent(model, instructions=full_prompt)
def create_follow_up_agent( def create_follow_up_agent(
@@ -52,7 +52,7 @@ def create_follow_up_agent(
provider = GoogleProvider(api_key=api_key) provider = GoogleProvider(api_key=api_key)
model = GoogleModel(model_name, provider=provider) model = GoogleModel(model_name, provider=provider)
prompt = system_prompt or DEFAULT_FOLLOW_UP prompt = system_prompt or DEFAULT_FOLLOW_UP
return Agent(model, output_type=FollowUpOptions, system_prompt=prompt) return Agent(model, output_type=FollowUpOptions, instructions=prompt)
def build_message_history(history: list[dict[str, str]]) -> list[ModelMessage]: def build_message_history(history: list[dict[str, str]]) -> list[ModelMessage]:
@@ -99,17 +99,17 @@ async def stream_response( # noqa: PLR0913
async def get_follow_ups( async def get_follow_ups(
follow_up_agent: Agent[None, FollowUpOptions], follow_up_agent: Agent[None, FollowUpOptions],
history: list[dict[str, str]], history: list[dict[str, str]],
image: ImageData | None = None, images: list[ImageData] | None = None,
) -> list[str]: ) -> list[str]:
message_history = build_message_history(history) if history else None message_history = build_message_history(history) if history else None
if image: if images:
prompt: list[str | BinaryContent] = [ prompt: list[str | BinaryContent] = ["Process this:"]
"Suggest follow-up options based on this conversation and image.", prompt.extend(
BinaryContent(data=image.data, media_type=image.media_type), BinaryContent(data=img.data, media_type=img.media_type) for img in images
] )
else: else:
prompt = "Suggest follow-up questions based on this conversation." # type: ignore[assignment] prompt = "Process this conversation." # type: ignore[assignment]
result = await follow_up_agent.run(prompt, message_history=message_history) result = await follow_up_agent.run(prompt, message_history=message_history)
return result.output["options"] return result.output["options"]

View File

@@ -12,15 +12,13 @@ When asked for DETAILS on a specific problem (or a problem number):
- Academic style, as it would be written in a notebook - Academic style, as it would be written in a notebook
- Step by step, clean, no fluff""" - Step by step, clean, no fluff"""
EXAM_FOLLOW_UP = """You see a problem set image. List available problem numbers. EXAM_FOLLOW_UP = """Look at the problem set image and list problem numbers as options.
Output only the numbers that exist in the image, like: 1, 2, 3, 4, 5 If problems have sub-parts (a, b, c), list as: 1a, 1b, 2a, etc.
If problems have letters (a, b, c), list them as: 1a, 1b, 2a, etc. Only output identifiers that exist in the image."""
Keep it minimal - just the identifiers.
Then, if applicable, output some possible followups of conversation"""
DEFAULT_FOLLOW_UP = ( DEFAULT_FOLLOW_UP = (
"Based on the conversation, suggest 3 short follow-up questions " "Based on the conversation, suggest 3 short follow-up questions "
"the user might want to ask. Be concise, each under 50 chars." "the user might want to ask. Each option should be under 50 characters."
) )
SUMMARIZE_PROMPT = """You are summarize agent. You may receive: SUMMARIZE_PROMPT = """You are summarize agent. You may receive:

View File

@@ -26,6 +26,9 @@
} }
handle { handle {
request_body {
max_size 50MB
}
reverse_proxy stealth-ai-relay-frontend:3000 reverse_proxy stealth-ai-relay-frontend:3000
} }
} }

View File

@@ -69,6 +69,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- PUBLIC_CONVEX_URL=${PUBLIC_CONVEX_URL}
image: stealth-ai-relay/frontend image: stealth-ai-relay/frontend
profiles: profiles:
- frontend - frontend
@@ -85,6 +87,8 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- PUBLIC_CONVEX_URL=${PUBLIC_CONVEX_URL}
image: stealth-ai-relay/frontend image: stealth-ai-relay/frontend
volumes: volumes:
- ./frontend:/app - ./frontend:/app
@@ -121,6 +125,21 @@ services:
browserless: browserless:
entrypoint: [ "python" ] entrypoint: [ "python" ]
migrate:
image: stealth-ai-relay/frontend
volumes:
- ./frontend:/app
- /app/node_modules
env_file:
- "frontend/.env"
- ".env"
profiles:
- migrate
networks:
database:
entrypoint: bunx
command: convex dev
convex-dashboard: convex-dashboard:
image: ghcr.io/get-convex/convex-dashboard:latest image: ghcr.io/get-convex/convex-dashboard:latest
stop_grace_period: 10s stop_grace_period: 10s

3
frontend/.gitignore vendored
View File

@@ -21,6 +21,3 @@ Thumbs.db
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Convex
src/lib/convex/_generated

View File

@@ -3,6 +3,9 @@ FROM oven/bun:alpine
ENV TERM=xterm-256color ENV TERM=xterm-256color
ENV COLORTERM=truecolor ENV COLORTERM=truecolor
ARG PUBLIC_CONVEX_URL
ENV PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
WORKDIR /app WORKDIR /app
COPY package.json bun.lock* ./ COPY package.json bun.lock* ./

12
frontend/src/app.d.ts vendored
View File

@@ -1,12 +1,10 @@
// See https://svelte.dev/docs/kit/types#app.d.ts import type { ConvexHttpClient } from 'convex/browser';
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} interface Locals {
// interface Locals {} convex: ConvexHttpClient;
// interface PageData {} }
// interface PageState {}
// interface Platform {}
} }
} }

View File

@@ -1,35 +0,0 @@
/* 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,8 @@
import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.convex = new ConvexHttpClient(PUBLIC_CONVEX_URL);
return resolve(event);
};

View File

@@ -17,19 +17,19 @@
} }
</script> </script>
<form onsubmit={handleSubmit} class="flex gap-2"> <form onsubmit={handleSubmit} class="flex gap-1">
<input <input
type="text" type="text"
bind:value bind:value
{disabled} {disabled}
placeholder="Message..." placeholder="..."
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" class="min-w-0 flex-1 rounded bg-neutral-800 px-2 py-1 text-[10px] text-white placeholder-neutral-500 outline-none"
/> />
<button <button
type="submit" type="submit"
{disabled} {disabled}
class="rounded-lg bg-blue-600 px-3 py-2 text-[11px] text-white transition-colors hover:bg-blue-500 disabled:opacity-50" class="shrink-0 rounded bg-blue-600 px-2 py-1 text-[10px] text-white disabled:opacity-50"
> >
Send &gt;
</button> </button>
</form> </form>

View File

@@ -0,0 +1,109 @@
import { ConvexHttpClient } from 'convex/browser';
import { getContext, setContext } from 'svelte';
import type { FunctionReference, FunctionArgs, FunctionReturnType } from 'convex/server';
const POLLING_CONTEXT_KEY = 'convex-polling';
const POLL_INTERVAL = 1000;
type PollingContext = {
client: ConvexHttpClient;
};
export function hasWebSocketSupport(): boolean {
if (typeof window === 'undefined') return true;
try {
return 'WebSocket' in window && typeof WebSocket !== 'undefined';
} catch {
return false;
}
}
export function setupPollingConvex(url: string): void {
const client = new ConvexHttpClient(url);
setContext<PollingContext>(POLLING_CONTEXT_KEY, { client });
}
export function usePollingClient(): ConvexHttpClient {
const ctx = getContext<PollingContext>(POLLING_CONTEXT_KEY);
if (!ctx) {
throw new Error('Convex polling client not set up. Call setupPollingConvex first.');
}
return ctx.client;
}
type QueryState<T> = {
data: T | undefined;
error: Error | null;
isLoading: boolean;
};
export function usePollingMutation<Mutation extends FunctionReference<'mutation'>>(
mutation: Mutation
): (args: FunctionArgs<Mutation>) => Promise<FunctionReturnType<Mutation>> {
const client = usePollingClient();
return (args: FunctionArgs<Mutation>) => client.mutation(mutation, args);
}
export function usePollingQuery<Query extends FunctionReference<'query'>>(
query: Query,
argsGetter: () => FunctionArgs<Query> | 'skip'
): { data: FunctionReturnType<Query> | undefined; error: Error | null; isLoading: boolean } {
const client = usePollingClient();
// eslint-disable-next-line prefer-const
let state = $state<QueryState<FunctionReturnType<Query>>>({
data: undefined,
error: null,
isLoading: true
});
let intervalId: ReturnType<typeof setInterval> | null = null;
let lastArgsJson = '';
async function poll() {
const args = argsGetter();
if (args === 'skip') {
state.isLoading = false;
return;
}
const argsJson = JSON.stringify(args);
if (argsJson !== lastArgsJson) {
state.isLoading = true;
lastArgsJson = argsJson;
}
try {
const result = await client.query(query, args);
state.data = result;
state.error = null;
state.isLoading = false;
} catch (err) {
state.error = err instanceof Error ? err : new Error(String(err));
state.isLoading = false;
}
}
$effect(() => {
poll();
intervalId = setInterval(poll, POLL_INTERVAL);
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
});
return {
get data() {
return state.data;
},
get error() {
return state.error;
},
get isLoading() {
return state.isLoading;
}
};
}

View File

@@ -0,0 +1,55 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as chats from "../chats.js";
import type * as messages from "../messages.js";
import type * as pendingGenerations from "../pendingGenerations.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
chats: typeof chats;
messages: typeof messages;
pendingGenerations: typeof pendingGenerations;
users: typeof users;
}>;
/**
* 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

@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { anyApi, componentsGeneric } from 'convex/server'; import { anyApi, componentsGeneric } from "convex/server";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.

View File

@@ -8,29 +8,29 @@
* @module * @module
*/ */
import { AnyDataModel } from 'convex/server'; import type {
import type { GenericId } from 'convex/values'; DataModelFromSchemaDefinition,
DocumentByName,
/** TableNamesInDataModel,
* No `schema.ts` file found! SystemTableNames,
* } from "convex/server";
* This generated code has permissive types like `Doc = any` because import type { GenericId } from "convex/values";
* Convex doesn't know your schema. If you'd like more type safety, see import schema from "../schema.js";
* 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. * The names of all of your Convex tables.
*/ */
export type TableNames = string; export type TableNames = TableNamesInDataModel<DataModel>;
/** /**
* The type of a document stored in Convex. * The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/ */
export type Doc = any; export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/** /**
* An identifier for a document in Convex. * An identifier for a document in Convex.
@@ -42,8 +42,11 @@ export type Doc = any;
* *
* IDs are just strings at runtime, but this type can be used to distinguish them from other * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/ */
export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>; export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/** /**
* A type describing your Convex data model. * A type describing your Convex data model.
@@ -54,4 +57,4 @@ export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>
* This type is used to parameterize methods like `queryGeneric` and * This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe. * `mutationGeneric` to make them type-safe.
*/ */
export type DataModel = AnyDataModel; export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View File

@@ -9,17 +9,17 @@
*/ */
import { import {
ActionBuilder, ActionBuilder,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
GenericActionCtx, GenericActionCtx,
GenericMutationCtx, GenericMutationCtx,
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter GenericDatabaseWriter,
} from 'convex/server'; } from "convex/server";
import type { DataModel } from './dataModel.js'; import type { DataModel } from "./dataModel.js";
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
@@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js';
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @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. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const query: QueryBuilder<DataModel, 'public'>; export declare const query: QueryBuilder<DataModel, "public">;
/** /**
* Define a query that is only accessible from other Convex functions (but not from the client). * Define a query that is only accessible from other Convex functions (but not from the client).
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @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. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>; export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/** /**
* Define a mutation in this Convex app's public API. * Define a mutation in this Convex app's public API.
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @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. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const mutation: MutationBuilder<DataModel, 'public'>; export declare const mutation: MutationBuilder<DataModel, "public">;
/** /**
* Define a mutation that is only accessible from other Convex functions (but not from the client). * Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @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. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>; export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/** /**
* Define an action in this Convex app's public API. * Define an action in this Convex app's public API.
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
* @param func - The action. It receives an {@link ActionCtx} as its first argument. * @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. * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/ */
export declare const action: ActionBuilder<DataModel, 'public'>; export declare const action: ActionBuilder<DataModel, "public">;
/** /**
* Define an action that is only accessible from other Convex functions (but not from the client). * Define an action that is only accessible from other Convex functions (but not from the client).
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @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. * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalAction: ActionBuilder<DataModel, 'internal'>; export declare const internalAction: ActionBuilder<DataModel, "internal">;
/** /**
* Define an HTTP action. * Define an HTTP action.

View File

@@ -9,14 +9,14 @@
*/ */
import { import {
actionGeneric, actionGeneric,
httpActionGeneric, httpActionGeneric,
queryGeneric, queryGeneric,
mutationGeneric, mutationGeneric,
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric internalQueryGeneric,
} from 'convex/server'; } from "convex/server";
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.

View File

@@ -43,7 +43,7 @@ export const clear = mutation({
.collect(); .collect();
for (const message of messages) { for (const message of messages) {
if (args.preserveImages && message.imageStorageId) { if (args.preserveImages && message.imageBase64) {
continue; continue;
} }
await ctx.db.delete(message._id); await ctx.db.delete(message._id);

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { internalMutation, mutation, query } from './_generated/server'; import { mutation, query } from './_generated/server';
export const listByChat = query({ export const listByChat = query({
args: { chatId: v.id('chats') }, args: { chatId: v.id('chats') },
@@ -10,7 +10,7 @@ export const listByChat = query({
chatId: v.id('chats'), chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')), role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(), content: v.string(),
imageStorageId: v.optional(v.id('_storage')), imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()), imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())), followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')), source: v.union(v.literal('telegram'), v.literal('web')),
@@ -33,7 +33,7 @@ export const create = mutation({
role: v.union(v.literal('user'), v.literal('assistant')), role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(), content: v.string(),
source: v.union(v.literal('telegram'), v.literal('web')), source: v.union(v.literal('telegram'), v.literal('web')),
imageStorageId: v.optional(v.id('_storage')), imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()), imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())), followUpOptions: v.optional(v.array(v.string())),
isStreaming: v.optional(v.boolean()) isStreaming: v.optional(v.boolean())
@@ -45,7 +45,7 @@ export const create = mutation({
role: args.role, role: args.role,
content: args.content, content: args.content,
source: args.source, source: args.source,
imageStorageId: args.imageStorageId, imageBase64: args.imageBase64,
imageMediaType: args.imageMediaType, imageMediaType: args.imageMediaType,
followUpOptions: args.followUpOptions, followUpOptions: args.followUpOptions,
createdAt: Date.now(), createdAt: Date.now(),
@@ -132,7 +132,7 @@ export const getLastAssistantMessage = query({
chatId: v.id('chats'), chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')), role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(), content: v.string(),
imageStorageId: v.optional(v.id('_storage')), imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()), imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())), followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')), source: v.union(v.literal('telegram'), v.literal('web')),
@@ -152,21 +152,12 @@ export const getLastAssistantMessage = query({
} }
}); });
export const generateUploadUrl = mutation({ export const getChatImages = query({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
}
});
export const getImageUrls = query({
args: { chatId: v.id('chats') }, args: { chatId: v.id('chats') },
returns: v.array( returns: v.array(
v.object({ v.object({
storageId: v.id('_storage'), base64: v.string(),
mediaType: v.string(), mediaType: v.string()
url: v.union(v.string(), v.null())
}) })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -175,41 +166,11 @@ export const getImageUrls = query({
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId)) .withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect(); .collect();
const imageMessages = messages.filter((m) => m.imageStorageId && m.imageMediaType); return messages
const results = []; .filter((m) => m.imageBase64 && m.imageMediaType)
.map((m) => ({
for (const msg of imageMessages) { base64: m.imageBase64!,
if (msg.imageStorageId && msg.imageMediaType) { mediaType: m.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

@@ -23,8 +23,9 @@ export default defineSchema({
chatId: v.id('chats'), chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')), role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(), content: v.string(),
imageStorageId: v.optional(v.id('_storage')), imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()), imageMediaType: v.optional(v.string()),
imageStorageId: v.optional(v.id('_storage')),
followUpOptions: v.optional(v.array(v.string())), followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')), source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(), createdAt: v.number(),

View File

@@ -3,10 +3,19 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte'; import { setupConvex } from 'convex-svelte';
import { hasWebSocketSupport, setupPollingConvex } from '$lib/convex-polling.svelte';
import { setContext } from 'svelte';
let { children } = $props(); let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL); const usePolling = !hasWebSocketSupport();
setContext('convex-use-polling', usePolling);
if (usePolling) {
setupPollingConvex(PUBLIC_CONVEX_URL);
} else {
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,18 +1,35 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { getContext } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
import { api } from '$lib/convex/_generated/api'; import { api } from '$lib/convex/_generated/api';
import ChatMessage from '$lib/components/ChatMessage.svelte'; import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatInput from '$lib/components/ChatInput.svelte'; import ChatInput from '$lib/components/ChatInput.svelte';
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte'; import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
const usePolling = getContext<boolean>('convex-use-polling') ?? false;
let mnemonic = $derived(page.params.mnemonic); let mnemonic = $derived(page.params.mnemonic);
const client = useConvexClient();
const chatData = useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip')); const chatDataWs = usePolling
const messagesQuery = useQuery(api.messages.listByChat, () => ? null
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip' : useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
); const chatDataPoll = usePolling
? usePollingQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'))
: null;
const chatData = $derived(usePolling ? chatDataPoll! : chatDataWs!);
const messagesQueryWs = usePolling
? null
: useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
const messagesQueryPoll = usePolling
? usePollingQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
)
: null;
const messagesQuery = $derived(usePolling ? messagesQueryPoll! : messagesQueryWs!);
let messages = $derived(messagesQuery.data ?? []); let messages = $derived(messagesQuery.data ?? []);
let lastMessage = $derived(messages[messages.length - 1]); let lastMessage = $derived(messages[messages.length - 1]);
@@ -22,53 +39,85 @@
: [] : []
); );
let prevMessageCount = 0;
let prevLastMessageId: string | undefined;
$effect(() => { $effect(() => {
if (messages.length) { const count = messages.length;
const lastId = lastMessage?._id;
if (count > prevMessageCount || (lastId && lastId !== prevLastMessageId)) {
prevMessageCount = count;
prevLastMessageId = lastId;
window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, document.body.scrollHeight);
} }
}); });
const clientWs = usePolling ? null : useConvexClient();
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
async function sendMessage(content: string) { async function sendMessage(content: string) {
const chat = chatData.data?.chat; const chat = chatData.data?.chat;
if (!chat) return; if (!chat) return;
await client.mutation(api.messages.create, { if (usePolling && createMessagePoll) {
chatId: chat._id, await createMessagePoll({
role: 'user', chatId: chat._id,
content, role: 'user',
source: 'web' content,
}); source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
}
} }
async function summarize() { async function summarize() {
const chat = chatData.data?.chat; const chat = chatData.data?.chat;
if (!chat) return; if (!chat) return;
await client.mutation(api.messages.create, { if (usePolling && createMessagePoll) {
chatId: chat._id, await createMessagePoll({
role: 'user', chatId: chat._id,
content: '/summarize', role: 'user',
source: 'web' content: '/summarize',
}); source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
}
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Chat</title> <title>Chat</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</svelte:head> </svelte:head>
<div class="min-h-dvh bg-black text-white"> <div class="min-h-dvh bg-black p-1.5 text-white">
{#if chatData.isLoading} {#if chatData.isLoading}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Loading...</div> <div class="py-4 text-center text-xs text-neutral-500">Loading...</div>
{:else if chatData.error} {:else if chatData.error}
<div class="flex min-h-dvh items-center justify-center text-red-500"> <div class="py-4 text-center text-red-500">
Error: {chatData.error.toString()} <div class="text-xs">Error</div>
<div class="text-[8px] break-all">{chatData.error.message}</div>
</div> </div>
{:else if !chatData.data} {:else if !chatData.data}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Chat not found</div> <div class="py-4 text-center text-xs text-neutral-500">Not found</div>
{:else} {:else}
<div class="space-y-1.5 p-2"> <div class="space-y-1">
{#each messages as message (message._id)} {#each messages as message (message._id)}
<ChatMessage <ChatMessage
role={message.role} role={message.role}
@@ -79,22 +128,21 @@
</div> </div>
{#if followUpOptions.length > 0} {#if followUpOptions.length > 0}
<div class="border-t border-neutral-800 px-2 py-1.5"> <div class="mt-2">
<FollowUpButtons options={followUpOptions} onselect={sendMessage} /> <FollowUpButtons options={followUpOptions} onselect={sendMessage} />
</div> </div>
{/if} {/if}
<div class="border-t border-neutral-800 px-2 pt-1.5"> <div class="mt-2 flex gap-1">
<button <button
onclick={summarize} onclick={summarize}
class="rounded bg-neutral-800 px-2 py-1 text-[10px] text-neutral-400" class="shrink-0 rounded bg-neutral-800 px-1.5 py-0.5 text-[8px] text-neutral-400"
> >
/summarize /sum
</button> </button>
</div> <div class="flex-1">
<ChatInput onsubmit={sendMessage} />
<div class="p-2 pt-1"> </div>
<ChatInput onsubmit={sendMessage} />
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,91 @@
import { api } from '$lib/convex/_generated/api';
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
function detectImageType(bytes: Uint8Array): string | null {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return 'image/png';
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
return 'image/gif';
}
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) {
return 'image/webp';
}
return null;
}
export const POST: RequestHandler = async ({ params, request, locals }) => {
const mnemonic = params.mnemonic;
const chatData = await locals.convex.query(api.chats.getByMnemonic, { mnemonic });
if (!chatData) {
throw error(404, 'Chat not found');
}
const rawContentType = request.headers.get('content-type') || '';
const caption = request.headers.get('x-caption') || '';
console.log('[POST /{mnemonic}] headers:', Object.fromEntries(request.headers.entries()));
console.log('[POST /{mnemonic}] content-type:', rawContentType);
let base64: string;
let mediaType: string;
if (rawContentType.includes('multipart/form-data')) {
const formData = await request.formData();
const keys = [...formData.keys()];
console.log('[POST /{mnemonic}] formData keys:', keys);
let file: File | null = null;
for (const key of ['file', 'image', 'photo', 'upload', 'attachment', ...keys]) {
const value = formData.get(key);
if (value instanceof File) {
file = value;
console.log('[POST /{mnemonic}] found file in field:', key);
break;
}
}
if (!file) {
throw error(400, `No file found in form data. Keys: ${keys.join(', ')}`);
}
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
base64 = Buffer.from(buffer).toString('base64');
mediaType = detectImageType(bytes) || file.type || 'image/jpeg';
console.log('[POST /{mnemonic}] file:', file.name, file.type, 'size:', buffer.byteLength);
} else if (rawContentType.includes('application/x-www-form-urlencoded')) {
throw error(400, 'Use Form with File field, not URL-encoded form');
} else {
const buffer = await request.arrayBuffer();
const bytes = new Uint8Array(buffer);
base64 = Buffer.from(buffer).toString('base64');
mediaType = detectImageType(bytes) || rawContentType || 'image/jpeg';
console.log('[POST /{mnemonic}] raw bytes size:', buffer.byteLength);
console.log('[POST /{mnemonic}] detected type:', mediaType);
}
if (!base64 || base64.length === 0) {
throw error(400, 'Empty image data');
}
await locals.convex.mutation(api.messages.create, {
chatId: chatData._id,
role: 'user',
content: caption,
source: 'web',
imageBase64: base64,
imageMediaType: mediaType
});
return new Response(JSON.stringify({ ok: true, mediaType, size: base64.length }), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -1,5 +1,17 @@
@import 'tailwindcss'; @import 'tailwindcss';
html,
body {
background: black;
min-height: 100%;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}
* {
-webkit-tap-highlight-color: transparent;
}
.prose-mini h1, .prose-mini h1,
.prose-mini h2, .prose-mini h2,
.prose-mini h3, .prose-mini h3,

View File

@@ -2,6 +2,4 @@ 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({ export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
plugins: [tailwindcss(), sveltekit()]
});