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
53 changed files with 91 additions and 4616 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: recreate down reset hard-reset restart frontend deploy rebuild migrate convex-key script
.PHONY: recreate down restart frontend deploy rebuild migrate convex-key script
recreate:
docker compose --profile services up -d
@@ -6,20 +6,11 @@ recreate:
down:
docker compose --profile services down
reset:
$(MAKE) down
$(MAKE) recreate
hard-reset:
docker compose down
docker compose up -d
restart:
docker compose --profile services restart
frontend:
docker compose build frontend
$(MAKE) migrate
docker compose up -d frontend
deploy:
@@ -34,13 +25,7 @@ migrate:
docker compose run --rm migrate
convex-key:
@output=$$(docker compose exec convex ./generate_admin_key.sh 2>&1); \
echo "$$output"; \
if echo "$$output" | grep -q "Admin key:"; then \
key=$$(echo "$$output" | tail -1); \
sed -i '' 's#^CONVEX_SELF_HOSTED_ADMIN_KEY=.*#CONVEX_SELF_HOSTED_ADMIN_KEY='"$$key"'#' frontend/.env; \
echo "Updated frontend/.env with new admin key"; \
fi
docker compose exec convex ./generate_admin_key.sh
script:
@cd backend && docker compose --profile scripts run --rm script-runner scripts/$(subst .,/,$(word 2,$(MAKECMDGOALS))).py $(wordlist 3,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))

View File

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

View File

@@ -17,8 +17,6 @@ async def startup(bot: Bot) -> None:
types.BotCommand(command="/model", description="Change AI model"),
types.BotCommand(command="/presets", description="Show prompt presets"),
types.BotCommand(command="/preset", description="Apply a preset"),
types.BotCommand(command="/proxy", description="Proxy chat to another bot"),
types.BotCommand(command="/inject", description="Inject knowledge base"),
]
)
logger.info(f"[green]Started as[/] @{(await bot.me()).username}")

View File

@@ -1,10 +0,0 @@
from aiogram import Router
from .collection import router as collection_router
from .handler import router as command_router
router = Router()
router.include_routers(command_router, collection_router)
__all__ = ["router"]

View File

@@ -1,97 +0,0 @@
import io
from aiogram import Bot, F, Router, types
from aiogram.filters import Filter
from convex import ConvexInt64
from utils import env
from utils.convex import ConvexClient
router = Router()
convex = ConvexClient(env.convex_url)
class InInjectCollectionMode(Filter):
async def __call__(self, message: types.Message) -> bool | dict:
if not message.from_user:
return False
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)}
)
if not user or not user.get("injectCollectionMode"):
return False
return {
"inject_user": user,
"inject_collection_mode": user["injectCollectionMode"],
}
in_collection_mode = InInjectCollectionMode()
@router.message(in_collection_mode, F.text & ~F.text.startswith("/"))
async def on_text_in_collection_mode(
message: types.Message, inject_user: dict, inject_collection_mode: dict
) -> None:
if not message.text:
return
db_id = inject_collection_mode["injectDatabaseId"]
db = await convex.query("inject:getDatabaseById", {"injectDatabaseId": db_id})
db_name = db["name"] if db else "database"
await convex.mutation(
"inject:setContent", {"injectDatabaseId": db_id, "content": message.text}
)
await convex.mutation(
"users:stopInjectCollectionMode", {"userId": inject_user["_id"]}
)
await message.answer(
f"✓ Text saved to '{db_name}'.\n\n"
f"Connect it with: <code>/inject connect {db_name}</code>",
parse_mode="HTML",
)
@router.message(in_collection_mode, F.document)
async def on_document_in_collection_mode(
message: types.Message, bot: Bot, inject_user: dict, inject_collection_mode: dict
) -> None:
if not message.document:
return
doc = message.document
db_id = inject_collection_mode["injectDatabaseId"]
db = await convex.query("inject:getDatabaseById", {"injectDatabaseId": db_id})
db_name = db["name"] if db else "database"
file = await bot.get_file(doc.file_id)
if not file.file_path:
await message.answer("Failed to download file.")
return
buffer = io.BytesIO()
await bot.download_file(file.file_path, buffer)
text = buffer.getvalue().decode("utf-8")
await convex.mutation(
"inject:setContent", {"injectDatabaseId": db_id, "content": text}
)
await convex.mutation(
"users:stopInjectCollectionMode", {"userId": inject_user["_id"]}
)
file_name = doc.file_name or "file"
await message.answer(
f"'{file_name}' saved to '{db_name}'.\n\n"
f"Connect it with: <code>/inject connect {db_name}</code>",
parse_mode="HTML",
)

View File

@@ -1,172 +0,0 @@
from aiogram import Router, types
from aiogram.filters import Command
from convex import ConvexInt64
from utils import env
from utils.convex import ConvexClient
router = Router()
convex = ConvexClient(env.convex_url)
@router.message(Command("inject"))
async def on_inject(message: types.Message) -> None:
if not message.from_user or not message.text:
return
args = message.text.split()[1:]
if not args:
await show_usage(message)
return
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)}
)
if not user:
await message.answer("Use /apikey first to set your Gemini API key.")
return
user_id = user["_id"]
command = args[0]
if command == "list":
await list_databases(message, user_id)
return
if len(args) < 2: # noqa: PLR2004
await show_usage(message)
return
db_name = args[1]
if command == "create":
await create_database(message, user_id, db_name)
elif command == "connect":
await connect_database(message, user_id, db_name)
elif command == "disconnect":
await disconnect_database(message, user_id, db_name)
elif command == "clear":
await clear_database(message, user_id, db_name)
else:
await show_usage(message)
async def show_usage(message: types.Message) -> None:
await message.answer(
"<b>Inject Commands:</b>\n\n"
"<code>/inject list</code> - List inject databases\n\n"
"<code>/inject create &lt;name&gt;</code> - Create and upload one file\n"
"<code>/inject connect &lt;name&gt;</code> - Connect to all chats\n"
"<code>/inject disconnect &lt;name&gt;</code> - Disconnect\n"
"<code>/inject clear &lt;name&gt;</code> - Delete database",
parse_mode="HTML",
)
async def list_databases(message: types.Message, user_id: str) -> None:
databases = await convex.query("inject:listDatabases", {"userId": user_id})
connections = await convex.query(
"injectConnections:getActiveForUser", {"userId": user_id}
)
connected_db_ids = {conn["injectDatabaseId"] for conn in connections}
if not databases:
await message.answer(
"No inject databases found.\n\n"
"Create one with: <code>/inject create mydb</code>",
parse_mode="HTML",
)
return
lines = ["<b>Your inject databases:</b>\n"]
for db in databases:
status = ""
if db["_id"] in connected_db_ids:
status += " (connected)"
if not db.get("content"):
status += " (empty)"
lines.append(f"{db['name']}{status}")
await message.answer("\n".join(lines), parse_mode="HTML")
async def create_database(message: types.Message, user_id: str, db_name: str) -> None:
collection_mode = await convex.query(
"users:getInjectCollectionMode", {"userId": user_id}
)
if collection_mode:
await message.answer(
"Already waiting for a file. Send a file or text to complete."
)
return
db_id = await convex.mutation(
"inject:createDatabase", {"userId": user_id, "name": db_name}
)
await convex.mutation(
"users:startInjectCollectionMode",
{"userId": user_id, "injectDatabaseId": db_id},
)
await message.answer(
f"<b>Waiting for content for '{db_name}'</b>\n\n"
"Send a file (json, txt, csv, etc.) or a text message.\n"
"It will be saved automatically.",
parse_mode="HTML",
)
async def connect_database(message: types.Message, user_id: str, db_name: str) -> None:
db = await convex.query("inject:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(
f"Database '{db_name}' not found.\n"
f"Create it with: <code>/inject create {db_name}</code>",
parse_mode="HTML",
)
return
await convex.mutation(
"injectConnections:connect",
{"userId": user_id, "injectDatabaseId": db["_id"], "isGlobal": True},
)
await message.answer(f"'{db_name}' connected to all your chats.")
async def disconnect_database(
message: types.Message, user_id: str, db_name: str
) -> None:
db = await convex.query("inject:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(f"Database '{db_name}' not found.")
return
result = await convex.mutation(
"injectConnections:disconnect",
{"userId": user_id, "injectDatabaseId": db["_id"]},
)
if result:
await message.answer(f"'{db_name}' disconnected.")
else:
await message.answer(f"'{db_name}' was not connected.")
async def clear_database(message: types.Message, user_id: str, db_name: str) -> None:
db = await convex.query("inject:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(f"Database '{db_name}' not found.")
return
await convex.mutation("inject:deleteDatabase", {"injectDatabaseId": db["_id"]})
await message.answer(f"'{db_name}' deleted.")

View File

@@ -3,25 +3,14 @@ import base64
import contextlib
import io
import time
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware, Bot, F, Router, html, types
from aiogram import Bot, F, Router, html, types
from aiogram.enums import ChatAction
from aiogram.types import (
BufferedInputFile,
InputMediaPhoto,
KeyboardButton,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
TelegramObject,
)
from aiogram.types import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove
from convex import ConvexInt64
from bot.handlers.proxy.handler import get_proxy_config, increment_proxy_count
from bot.modules.ai import (
SUMMARIZE_PROMPT,
AgentDeps,
ImageData,
create_follow_up_agent,
create_text_agent,
@@ -34,45 +23,6 @@ from utils.convex import ConvexClient
router = Router()
convex = ConvexClient(env.convex_url)
ALBUM_COLLECT_DELAY = 0.5
class AlbumMiddleware(BaseMiddleware):
def __init__(self) -> None:
self.albums: dict[str, list[types.Message]] = {}
self.scheduled: set[str] = set()
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any: # noqa: ANN401
if not isinstance(event, types.Message) or not event.media_group_id:
return await handler(event, data)
album_id = event.media_group_id
if album_id not in self.albums:
self.albums[album_id] = []
self.albums[album_id].append(event)
if album_id in self.scheduled:
return None
self.scheduled.add(album_id)
await asyncio.sleep(ALBUM_COLLECT_DELAY)
messages = self.albums.pop(album_id, [])
self.scheduled.discard(album_id)
if messages:
data["album"] = messages
return await handler(messages[0], data)
return None
router.message.middleware(AlbumMiddleware())
EDIT_THROTTLE_SECONDS = 1.0
TELEGRAM_MAX_LENGTH = 4096
@@ -162,39 +112,6 @@ class StreamingState:
await self.update_message(self.pending_content, force=True)
class ProxyStreamingState:
def __init__(self, bot: Bot, chat_id: int, message: types.Message) -> None:
self.bot = bot
self.chat_id = chat_id
self.message = message
self.last_edit_time = 0.0
self.last_content = ""
self.pending_content: str | None = None
async def update_message(self, content: str, *, force: bool = False) -> None:
if content == self.last_content:
return
if len(content) > TELEGRAM_MAX_LENGTH:
display_content = content[: TELEGRAM_MAX_LENGTH - 3] + "..."
else:
display_content = content
now = time.monotonic()
if force or (now - self.last_edit_time) >= EDIT_THROTTLE_SECONDS:
with contextlib.suppress(Exception):
await self.message.edit_text(display_content)
self.last_edit_time = now
self.last_content = content
self.pending_content = None
else:
self.pending_content = content
async def flush(self) -> None:
if self.pending_content and self.pending_content != self.last_content:
await self.update_message(self.pending_content, force=True)
async def send_long_message(
bot: Bot, chat_id: int, text: str, reply_markup: ReplyKeyboardMarkup | None = None
) -> None:
@@ -206,13 +123,8 @@ async def send_long_message(
)
async def process_message_from_web( # noqa: C901, PLR0912, PLR0913, PLR0915
convex_user_id: str,
text: str,
bot: Bot,
convex_chat_id: str,
images_base64: list[str] | None = None,
images_media_types: list[str] | None = None,
async def process_message_from_web( # noqa: C901, PLR0912, PLR0915
convex_user_id: str, text: str, bot: Bot, convex_chat_id: str
) -> None:
user = await convex.query("users:getById", {"userId": convex_user_id})
@@ -223,61 +135,13 @@ async def process_message_from_web( # noqa: C901, PLR0912, PLR0913, PLR0915
is_summarize = text == "/summarize"
if tg_chat_id and not is_summarize:
if images_base64 and images_media_types:
if len(images_base64) == 1:
photo_bytes = base64.b64decode(images_base64[0])
await bot.send_photo(
tg_chat_id,
BufferedInputFile(photo_bytes, "photo.jpg"),
caption=f"📱 {text}" if text else "📱",
reply_markup=ReplyKeyboardRemove(),
)
else:
media = []
img_pairs = zip(images_base64, images_media_types, strict=True)
for i, (img_b64, _) in enumerate(img_pairs):
photo_bytes = base64.b64decode(img_b64)
caption = f"📱 {text}" if i == 0 and text else None
media.append(
InputMediaPhoto(
media=BufferedInputFile(photo_bytes, f"photo_{i}.jpg"),
caption=caption,
)
)
await bot.send_media_group(tg_chat_id, media)
else:
await bot.send_message(
tg_chat_id, f"📱 {html.quote(text)}", reply_markup=ReplyKeyboardRemove()
)
await bot.send_message(
tg_chat_id, f"📱 {html.quote(text)}", reply_markup=ReplyKeyboardRemove()
)
api_key = user["geminiApiKey"]
model_name = user.get("model", "gemini-3-pro-preview")
rag_connections = await convex.query(
"ragConnections:getActiveForUser", {"userId": convex_user_id}
)
rag_db_names: list[str] = []
if rag_connections:
for conn in rag_connections:
db = await convex.query(
"rag:getDatabaseById", {"ragDatabaseId": conn["ragDatabaseId"]}
)
if db:
rag_db_names.append(db["name"])
inject_connections = await convex.query(
"injectConnections:getActiveForUser", {"userId": convex_user_id}
)
inject_content = ""
if inject_connections:
for conn in inject_connections:
db = await convex.query(
"inject:getDatabaseById", {"injectDatabaseId": conn["injectDatabaseId"]}
)
if db and db.get("content"):
inject_content += db["content"] + "\n\n"
inject_content = inject_content.strip()
assistant_message_id = await convex.mutation(
"messages:create",
{
@@ -294,19 +158,8 @@ async def process_message_from_web( # noqa: C901, PLR0912, PLR0913, PLR0915
)
system_prompt = SUMMARIZE_PROMPT if is_summarize else user.get("systemPrompt")
if system_prompt and inject_content:
system_prompt = system_prompt.replace("{theory_database}", inject_content)
text_agent = create_text_agent(
api_key=api_key,
model_name=model_name,
system_prompt=system_prompt,
rag_db_names=rag_db_names if rag_db_names else None,
)
agent_deps = (
AgentDeps(user_id=convex_user_id, api_key=api_key, rag_db_names=rag_db_names)
if rag_db_names
else None
api_key=api_key, model_name=model_name, system_prompt=system_prompt
)
processing_msg = None
@@ -337,7 +190,7 @@ async def process_message_from_web( # noqa: C901, PLR0912, PLR0913, PLR0915
chat_images = await fetch_chat_images(convex_chat_id)
final_answer = await stream_response(
text_agent, prompt_text, hist, on_chunk, images=chat_images, deps=agent_deps
text_agent, prompt_text, hist, on_chunk, images=chat_images
)
if state:
@@ -403,14 +256,8 @@ async def process_message_from_web( # noqa: C901, PLR0912, PLR0913, PLR0915
await processing_msg.edit_text(truncated)
async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
user_id: int,
text: str,
bot: Bot,
chat_id: int,
*,
skip_user_message: bool = False,
skip_proxy_user_message: bool = False,
async def process_message(
user_id: int, text: str, bot: Bot, chat_id: int, *, skip_user_message: bool = False
) -> None:
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(user_id)}
@@ -431,43 +278,6 @@ async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
active_chat_id = user["activeChatId"]
api_key = user["geminiApiKey"]
model_name = user.get("model", "gemini-3-pro-preview")
convex_user_id = user["_id"]
proxy_config = get_proxy_config(chat_id)
proxy_state: ProxyStreamingState | None = None
if proxy_config and not skip_proxy_user_message:
with contextlib.suppress(Exception):
await proxy_config.proxy_bot.send_message(
proxy_config.target_chat_id, f"👤 {text}"
)
await increment_proxy_count(chat_id)
proxy_config = get_proxy_config(chat_id)
rag_connections = await convex.query(
"ragConnections:getActiveForUser", {"userId": convex_user_id}
)
rag_db_names: list[str] = []
if rag_connections:
for conn in rag_connections:
db = await convex.query(
"rag:getDatabaseById", {"ragDatabaseId": conn["ragDatabaseId"]}
)
if db:
rag_db_names.append(db["name"])
inject_connections = await convex.query(
"injectConnections:getActiveForUser", {"userId": convex_user_id}
)
inject_content = ""
if inject_connections:
for conn in inject_connections:
db = await convex.query(
"inject:getDatabaseById", {"injectDatabaseId": conn["injectDatabaseId"]}
)
if db and db.get("content"):
inject_content += db["content"] + "\n\n"
inject_content = inject_content.strip()
if not skip_user_message:
await convex.mutation(
@@ -495,40 +305,18 @@ async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
"messages:getHistoryForAI", {"chatId": active_chat_id, "limit": 50}
)
system_prompt = user.get("systemPrompt")
if system_prompt and inject_content:
system_prompt = system_prompt.replace("{theory_database}", inject_content)
text_agent = create_text_agent(
api_key=api_key,
model_name=model_name,
system_prompt=system_prompt,
rag_db_names=rag_db_names if rag_db_names else None,
)
agent_deps = (
AgentDeps(user_id=convex_user_id, api_key=api_key, rag_db_names=rag_db_names)
if rag_db_names
else None
api_key=api_key, model_name=model_name, system_prompt=user.get("systemPrompt")
)
processing_msg = await bot.send_message(chat_id, "...")
state = StreamingState(bot, chat_id, processing_msg)
if proxy_config:
proxy_processing_msg = await proxy_config.proxy_bot.send_message(
proxy_config.target_chat_id, "..."
)
proxy_state = ProxyStreamingState(
proxy_config.proxy_bot, proxy_config.target_chat_id, proxy_processing_msg
)
try:
await state.start_typing()
async def on_chunk(content: str) -> None:
await state.update_message(content)
if proxy_state:
await proxy_state.update_message(content)
await convex.mutation(
"messages:update",
{"messageId": assistant_message_id, "content": content},
@@ -537,17 +325,10 @@ async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
chat_images = await fetch_chat_images(active_chat_id)
final_answer = await stream_response(
text_agent,
text,
history[:-2],
on_chunk,
images=chat_images,
deps=agent_deps,
text_agent, text, history[:-2], on_chunk, images=chat_images
)
await state.flush()
if proxy_state:
await proxy_state.flush()
full_history = [*history[:-1], {"role": "assistant", "content": final_answer}]
follow_up_model = user.get("followUpModel", "gemini-2.5-flash-lite")
@@ -575,16 +356,6 @@ async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
keyboard = make_follow_up_keyboard(follow_ups)
await send_long_message(bot, chat_id, final_answer, keyboard)
if proxy_state and proxy_config:
with contextlib.suppress(Exception):
await proxy_state.message.delete()
parts = split_message(final_answer)
for part in parts:
await proxy_config.proxy_bot.send_message(
proxy_config.target_chat_id, part
)
await increment_proxy_count(chat_id)
except Exception as e: # noqa: BLE001
await state.stop_typing()
error_msg = f"Error: {e}"
@@ -598,9 +369,6 @@ async def process_message( # noqa: C901, PLR0912, PLR0913, PLR0915
)
with contextlib.suppress(Exception):
await processing_msg.edit_text(html.quote(error_msg[:TELEGRAM_MAX_LENGTH]))
if proxy_state:
with contextlib.suppress(Exception):
await proxy_state.message.edit_text(error_msg[:TELEGRAM_MAX_LENGTH])
async def send_to_telegram(user_id: int, text: str, bot: Bot) -> None:
@@ -630,98 +398,6 @@ async def on_text_message(message: types.Message, bot: Bot) -> None:
await process_message(message.from_user.id, message.text, bot, message.chat.id)
@router.message(F.media_group_id, F.photo)
async def on_album_message(
message: types.Message, bot: Bot, album: list[types.Message]
) -> None:
if not message.from_user:
return
await convex.mutation(
"users:getOrCreate",
{
"telegramId": ConvexInt64(message.from_user.id),
"telegramChatId": ConvexInt64(message.chat.id),
},
)
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 images according to your task"
images_base64: list[str] = []
images_media_types: list[str] = []
photos_bytes: list[bytes] = []
for msg in album:
if not msg.photo:
continue
photo = msg.photo[-1]
file = await bot.get_file(photo.file_id)
if not file.file_path:
continue
buffer = io.BytesIO()
await bot.download_file(file.file_path, buffer)
image_bytes = buffer.getvalue()
photos_bytes.append(image_bytes)
images_base64.append(base64.b64encode(image_bytes).decode())
ext = file.file_path.rsplit(".", 1)[-1].lower()
media_type = f"image/{ext}" if ext in ("png", "gif", "webp") else "image/jpeg"
images_media_types.append(media_type)
if not images_base64:
await message.answer("Failed to get photos.")
return
proxy_config = get_proxy_config(message.chat.id)
if proxy_config:
with contextlib.suppress(Exception):
media = []
for i, photo_bytes in enumerate(photos_bytes):
cap = f"👤 {caption}" if i == 0 else None
media.append(
InputMediaPhoto(
media=BufferedInputFile(photo_bytes, f"photo_{i}.jpg"),
caption=cap,
)
)
await proxy_config.proxy_bot.send_media_group(
proxy_config.target_chat_id, media
)
await increment_proxy_count(message.chat.id)
active_chat_id = user["activeChatId"]
await convex.mutation(
"messages:create",
{
"chatId": active_chat_id,
"role": "user",
"content": caption,
"source": "telegram",
"imagesBase64": images_base64,
"imagesMediaTypes": images_media_types,
},
)
await process_message(
message.from_user.id,
caption,
bot,
message.chat.id,
skip_user_message=True,
skip_proxy_user_message=True,
)
@router.message(F.photo)
async def on_photo_message(message: types.Message, bot: Bot) -> None:
if not message.from_user or not message.photo:
@@ -759,16 +435,6 @@ async def on_photo_message(message: types.Message, bot: Bot) -> None:
ext = file.file_path.rsplit(".", 1)[-1].lower()
media_type = f"image/{ext}" if ext in ("png", "gif", "webp") else "image/jpeg"
proxy_config = get_proxy_config(message.chat.id)
if proxy_config:
with contextlib.suppress(Exception):
await proxy_config.proxy_bot.send_photo(
proxy_config.target_chat_id,
BufferedInputFile(image_bytes, "photo.jpg"),
caption=f"👤 {caption}",
)
await increment_proxy_count(message.chat.id)
active_chat_id = user["activeChatId"]
await convex.mutation(
"messages:create",
@@ -783,10 +449,5 @@ async def on_photo_message(message: types.Message, bot: Bot) -> None:
)
await process_message(
message.from_user.id,
caption,
bot,
message.chat.id,
skip_user_message=True,
skip_proxy_user_message=True,
message.from_user.id, caption, bot, message.chat.id, skip_user_message=True
)

View File

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

View File

@@ -1,131 +0,0 @@
import contextlib
from dataclasses import dataclass, field
from aiogram import Bot, Router, types
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import Command
router = Router()
@dataclass
class ProxyConfig:
bot_token: str
target_chat_id: int
proxy_bot: Bot
limit: int = 0
message_count: int = field(default=0, init=False)
proxy_states: dict[int, ProxyConfig] = {}
def get_proxy_config(chat_id: int) -> ProxyConfig | None:
return proxy_states.get(chat_id)
async def cleanup_proxy(chat_id: int) -> None:
config = proxy_states.pop(chat_id, None)
if config:
await config.proxy_bot.session.close()
async def increment_proxy_count(chat_id: int) -> bool:
config = proxy_states.get(chat_id)
if not config:
return False
config.message_count += 1
if config.limit > 0 and config.message_count >= config.limit:
await config.proxy_bot.send_message(
config.target_chat_id, f"🔗 Proxy finished ({config.limit} messages)"
)
await cleanup_proxy(chat_id)
return False
return True
@router.message(Command("proxy"))
async def on_proxy(message: types.Message) -> None: # noqa: C901, PLR0912
if not message.from_user or not message.text:
return
chat_id = message.chat.id
args = message.text.split(maxsplit=3)
if len(args) == 1:
config = get_proxy_config(chat_id)
if config:
cnt = f" ({config.message_count}/{config.limit})" if config.limit else ""
await message.answer(
f"Proxy active → chat {config.target_chat_id}{cnt}\n\n"
"Use /proxy deactivate to stop."
)
else:
await message.answer(
"Usage:\n"
"/proxy BOT_TOKEN CHAT_ID [LIMIT] - activate proxy\n"
"/proxy deactivate - stop proxy"
)
return
if args[1].lower() == "deactivate":
if chat_id in proxy_states:
await cleanup_proxy(chat_id)
await message.answer("✓ Proxy deactivated.")
else:
await message.answer("No active proxy.")
return
if len(args) < 3: # noqa: PLR2004
await message.answer("Usage: /proxy BOT_TOKEN CHAT_ID [LIMIT]")
return
bot_token = args[1]
try:
target_chat_id = int(args[2])
except ValueError:
await message.answer("Invalid chat ID. Must be a number.")
return
limit = 0
if len(args) >= 4: # noqa: PLR2004
with contextlib.suppress(ValueError):
limit = max(int(args[3]), 0)
if chat_id in proxy_states:
await cleanup_proxy(chat_id)
try:
proxy_bot = Bot(
token=bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
bot_info = await proxy_bot.get_me()
try:
await proxy_bot.send_message(target_chat_id, "🔗 Proxy connected")
except Exception as e: # noqa: BLE001
await proxy_bot.session.close()
await message.answer(f"Cannot send to chat {target_chat_id}: {e}")
return
proxy_states[chat_id] = ProxyConfig(
bot_token=bot_token,
target_chat_id=target_chat_id,
proxy_bot=proxy_bot,
limit=limit,
)
limit_text = f"\nLimit: {limit} messages" if limit > 0 else ""
await message.answer(
f"✓ Proxy activated via @{bot_info.username}\n"
f"Target: {target_chat_id}{limit_text}\n\n"
"All messages will be forwarded."
)
await message.delete()
except Exception as e: # noqa: BLE001
await message.answer(f"Invalid bot token: {e}")

View File

@@ -1,10 +0,0 @@
from aiogram import Router
from .collection import router as collection_router
from .handler import router as command_router
router = Router()
router.include_routers(command_router, collection_router)
__all__ = ["router"]

View File

@@ -1,94 +0,0 @@
import io
from uuid import uuid4
from aiogram import Bot, F, Router, types
from aiogram.filters import Filter
from convex import ConvexInt64
from utils import env
from utils.convex import ConvexClient
router = Router()
convex = ConvexClient(env.convex_url)
class InRagCollectionMode(Filter):
async def __call__(self, message: types.Message) -> bool | dict:
if not message.from_user:
return False
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)}
)
if not user or not user.get("ragCollectionMode"):
return False
return {"rag_user": user, "rag_collection_mode": user["ragCollectionMode"]}
in_collection_mode = InRagCollectionMode()
@router.message(in_collection_mode, F.text & ~F.text.startswith("/"))
async def on_text_in_collection_mode(
message: types.Message, rag_user: dict, rag_collection_mode: dict
) -> None:
if not message.text:
return
api_key = rag_user.get("geminiApiKey")
if not api_key:
return
await convex.action(
"rag:addContent",
{
"userId": rag_user["_id"],
"ragDatabaseId": rag_collection_mode["ragDatabaseId"],
"apiKey": api_key,
"text": message.text,
"key": str(uuid4()),
},
)
await message.answer("✓ Text added to knowledge base.")
@router.message(in_collection_mode, F.document)
async def on_document_in_collection_mode(
message: types.Message, bot: Bot, rag_user: dict, rag_collection_mode: dict
) -> None:
if not message.document:
return
api_key = rag_user.get("geminiApiKey")
if not api_key:
return
doc = message.document
if not doc.file_name or not doc.file_name.endswith(".txt"):
await message.answer("Only .txt files are supported for RAG.")
return
file = await bot.get_file(doc.file_id)
if not file.file_path:
await message.answer("Failed to download file.")
return
buffer = io.BytesIO()
await bot.download_file(file.file_path, buffer)
text = buffer.getvalue().decode("utf-8")
await convex.action(
"rag:addContent",
{
"userId": rag_user["_id"],
"ragDatabaseId": rag_collection_mode["ragDatabaseId"],
"apiKey": api_key,
"text": text,
"key": doc.file_name,
},
)
await message.answer(f"✓ File '{doc.file_name}' added to knowledge base.")

View File

@@ -1,217 +0,0 @@
from aiogram import Router, types
from aiogram.filters import Command
from convex import ConvexInt64
from utils import env
from utils.convex import ConvexClient
router = Router()
convex = ConvexClient(env.convex_url)
@router.message(Command("rag"))
async def on_rag(message: types.Message) -> None: # noqa: C901, PLR0911
if not message.from_user or not message.text:
return
args = message.text.split()[1:]
if not args:
await show_usage(message)
return
user = await convex.query(
"users:getByTelegramId", {"telegramId": ConvexInt64(message.from_user.id)}
)
if not user:
await message.answer("Use /apikey first to set your Gemini API key.")
return
if not user.get("geminiApiKey"):
await message.answer("Use /apikey first to set your Gemini API key.")
return
user_id = user["_id"]
if args[0] == "list":
await list_databases(message, user_id)
return
if args[0] == "save":
await save_collection(message, user_id)
return
db_name = args[0]
if len(args) < 2: # noqa: PLR2004
await show_db_usage(message, db_name)
return
command = args[1]
if command == "add":
await start_collection(message, user_id, db_name)
elif command == "connect":
await connect_database(message, user_id, db_name)
elif command == "disconnect":
await disconnect_database(message, user_id, db_name)
elif command == "clear":
await clear_database(message, user_id, user["geminiApiKey"], db_name)
else:
await show_db_usage(message, db_name)
async def show_usage(message: types.Message) -> None:
await message.answer(
"<b>RAG Commands:</b>\n\n"
"<code>/rag list</code> - List your RAG databases\n"
"<code>/rag save</code> - Exit collection mode\n\n"
"<code>/rag &lt;name&gt; add</code> - Start adding content\n"
"<code>/rag &lt;name&gt; connect</code> - Connect to all chats\n"
"<code>/rag &lt;name&gt; disconnect</code> - Disconnect\n"
"<code>/rag &lt;name&gt; clear</code> - Delete database",
parse_mode="HTML",
)
async def show_db_usage(message: types.Message, db_name: str) -> None:
await message.answer(
f"<b>Commands for '{db_name}':</b>\n\n"
f"<code>/rag {db_name} add</code> - Start adding content\n"
f"<code>/rag {db_name} connect</code> - Connect to all chats\n"
f"<code>/rag {db_name} disconnect</code> - Disconnect\n"
f"<code>/rag {db_name} clear</code> - Delete database",
parse_mode="HTML",
)
async def list_databases(message: types.Message, user_id: str) -> None:
databases = await convex.query("rag:listDatabases", {"userId": user_id})
connections = await convex.query(
"ragConnections:getActiveForUser", {"userId": user_id}
)
connected_db_ids = {conn["ragDatabaseId"] for conn in connections}
if not databases:
await message.answer(
"No RAG databases found.\n\nCreate one with: <code>/rag mydb add</code>",
parse_mode="HTML",
)
return
lines = ["<b>Your RAG databases:</b>\n"]
for db in databases:
status = " (connected)" if db["_id"] in connected_db_ids else ""
lines.append(f"{db['name']}{status}")
await message.answer("\n".join(lines), parse_mode="HTML")
async def start_collection(message: types.Message, user_id: str, db_name: str) -> None:
collection_mode = await convex.query(
"users:getRagCollectionMode", {"userId": user_id}
)
if collection_mode:
await message.answer(
"Already in collection mode. Use <code>/rag save</code> to exit first.",
parse_mode="HTML",
)
return
db_id = await convex.mutation(
"rag:createDatabase", {"userId": user_id, "name": db_name}
)
await convex.mutation(
"users:startRagCollectionMode", {"userId": user_id, "ragDatabaseId": db_id}
)
await message.answer(
f"📚 <b>Collection mode started for '{db_name}'</b>\n\n"
"Send text messages or .txt files to add content.\n"
"Use <code>/rag save</code> when done.",
parse_mode="HTML",
)
async def save_collection(message: types.Message, user_id: str) -> None:
collection_mode = await convex.query(
"users:getRagCollectionMode", {"userId": user_id}
)
if not collection_mode:
await message.answer("Not in collection mode.")
return
db = await convex.query(
"rag:getDatabaseById", {"ragDatabaseId": collection_mode["ragDatabaseId"]}
)
db_name = db["name"] if db else "database"
await convex.mutation("users:stopRagCollectionMode", {"userId": user_id})
await message.answer(
f"✓ Collection mode ended for '{db_name}'.\n\n"
f"Connect it with: <code>/rag {db_name} connect</code>",
parse_mode="HTML",
)
async def connect_database(message: types.Message, user_id: str, db_name: str) -> None:
db = await convex.query("rag:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(
f"Database '{db_name}' not found.\n"
f"Create it with: <code>/rag {db_name} add</code>",
parse_mode="HTML",
)
return
await convex.mutation(
"ragConnections:connect",
{"userId": user_id, "ragDatabaseId": db["_id"], "isGlobal": True},
)
await message.answer(f"'{db_name}' connected to all your chats.")
async def disconnect_database(
message: types.Message, user_id: str, db_name: str
) -> None:
db = await convex.query("rag:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(f"Database '{db_name}' not found.")
return
result = await convex.mutation(
"ragConnections:disconnect", {"userId": user_id, "ragDatabaseId": db["_id"]}
)
if result:
await message.answer(f"'{db_name}' disconnected.")
else:
await message.answer(f"'{db_name}' was not connected.")
async def clear_database(
message: types.Message, user_id: str, api_key: str, db_name: str
) -> None:
db = await convex.query("rag:getDatabase", {"userId": user_id, "name": db_name})
if not db:
await message.answer(f"Database '{db_name}' not found.")
return
result = await convex.action(
"rag:deleteDatabase", {"userId": user_id, "name": db_name, "apiKey": api_key}
)
if result:
await message.answer(f"'{db_name}' deleted.")
else:
await message.answer(f"Failed to delete '{db_name}'.")

View File

@@ -1,5 +1,4 @@
from .agent import (
AgentDeps,
ImageData,
StreamCallback,
create_follow_up_agent,
@@ -13,7 +12,6 @@ __all__ = [
"DEFAULT_FOLLOW_UP",
"PRESETS",
"SUMMARIZE_PROMPT",
"AgentDeps",
"ImageData",
"StreamCallback",
"create_follow_up_agent",

View File

@@ -7,22 +7,16 @@ from pydantic_ai import (
ModelMessage,
ModelRequest,
ModelResponse,
RunContext,
TextPart,
UserPromptPart,
)
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.providers.google import GoogleProvider
from utils import env
from utils.convex import ConvexClient
from utils.logging import logger
from .models import FollowUpOptions
from .prompts import DEFAULT_FOLLOW_UP
StreamCallback = Callable[[str], Awaitable[None]]
convex = ConvexClient(env.convex_url)
@dataclass
@@ -31,70 +25,21 @@ class ImageData:
media_type: str
@dataclass
class AgentDeps:
user_id: str
api_key: str
rag_db_names: list[str]
LATEX_INSTRUCTION = "For math, use LaTeX: $...$ inline, $$...$$ display."
DEFAULT_SYSTEM_PROMPT = (
"You are a helpful AI assistant. Provide clear, concise answers."
)
RAG_SYSTEM_ADDITION = (
" You have access to a knowledge base. Use the search_knowledge_base tool "
"to find relevant information when the user asks about topics that might "
"be covered in the knowledge base."
)
def create_text_agent(
api_key: str,
model_name: str = "gemini-3-pro-preview",
system_prompt: str | None = None,
rag_db_names: list[str] | None = None,
) -> Agent[AgentDeps, str] | Agent[None, str]:
) -> Agent[None, str]:
provider = GoogleProvider(api_key=api_key)
model = GoogleModel(model_name, provider=provider)
base_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
if rag_db_names:
full_prompt = f"{base_prompt}{RAG_SYSTEM_ADDITION} {LATEX_INSTRUCTION}"
agent: Agent[None, str] = Agent(
model, instructions=full_prompt, deps_type=AgentDeps
)
@agent.tool
async def search_knowledge_base(ctx: RunContext[AgentDeps], query: str) -> str:
"""Search the user's knowledge base for relevant information.
Args:
ctx: The run context containing user dependencies.
query: The search query to find relevant information.
Returns:
Relevant text from the knowledge base.
"""
logger.info(f"Searching knowledge base for {query}")
result = await convex.action(
"rag:searchMultiple",
{
"userId": ctx.deps.user_id,
"dbNames": ctx.deps.rag_db_names,
"apiKey": ctx.deps.api_key,
"query": query,
"limit": 5,
},
)
if result and result.get("text"):
return f"Knowledge base results:\n\n{result['text']}"
return "No relevant information found in the knowledge base."
return agent
full_prompt = f"{base_prompt} {LATEX_INSTRUCTION}"
return Agent(model, instructions=full_prompt)
@@ -123,13 +68,12 @@ def build_message_history(history: list[dict[str, str]]) -> list[ModelMessage]:
async def stream_response( # noqa: PLR0913
text_agent: Agent[AgentDeps, str] | Agent[None, str],
text_agent: Agent[None, str],
message: str,
history: list[dict[str, str]] | None = None,
on_chunk: StreamCallback | None = None,
image: ImageData | None = None,
images: list[ImageData] | None = None,
deps: AgentDeps | None = None,
) -> str:
message_history = build_message_history(history) if history else None
@@ -144,7 +88,7 @@ async def stream_response( # noqa: PLR0913
else:
prompt = message # type: ignore[assignment]
stream = text_agent.run_stream(prompt, message_history=message_history, deps=deps)
stream = text_agent.run_stream(prompt, message_history=message_history)
async with stream as result:
async for text in result.stream_text():
if on_chunk:

View File

@@ -1,6 +1,6 @@
EXAM_SYSTEM = """You help solve problem sets and exams.
When you receive just an IMAGE to process with problems:
When you receive an IMAGE with problems:
- Give HINTS in Russian for each problem
- Focus on key insights and potential difficulties,
give all formulas that will be helpful
@@ -9,101 +9,13 @@ give all formulas that will be helpful
When asked for DETAILS on a specific problem (or a problem number):
- Provide full structured solution in English
- Academic style, as it would be written in a notebook on real exam
- Step by step, clean, no fluff, no overcompications, reuse thoughts inside
one task, as you would write it on an exam, be consistent
- This is also true if you get a summary, and then problem number is asked"""
- Academic style, as it would be written in a notebook
- Step by step, clean, no fluff"""
EXAM_FOLLOW_UP = """Look at the problem set image and list ALL problem numbers as
options. Split by subparts ONLY if they are totally different tasks, not the steps of
one.
If there are multiple problem sets/sheets, break it down logically and specify set,
for example Group A: 1, Group A: 2a, Group B: 2b, etc.
Or, Theory: 1, Theory: 2a, Practice: 1, etc.
EXAM_FOLLOW_UP = """Look at the problem set image and list problem numbers as options.
If problems have sub-parts (a, b, c), list as: 1a, 1b, 2a, etc.
Only output identifiers that exist in the image."""
PROOFS_SYSTEM = """
You are an Examination Engine designed for Apple Watch output.
CONTEXT: You have a loaded JSON database of theoretical knowledge below.
<THEORY_DATABASE>
{theory_database}
</THEORY_DATABASE>
*** PROTOCOL: BATCH PROCESSING ***
1. IMAGE INPUT (Primary Mode):
- **DETECT ALL** tasks/questions visible in the image.
- **SOLVE ALL** of them sequentially in a single response.
- **ORDER:** Follow the numbering on the exam sheet (Ex 1, Ex 2, ...).
- **SEPARATOR:** Use "---" between tasks.
- Second image - treat as continuation
2. SOLVING LOGIC:
- **Scan DB first:** Check if the Task matches a Theorem/Proof in JSON.
- IF MATCH: Output `statement` AND `proof` VERBATIM from JSON
(as requested in task)
- IF PARTIAL MATCH (e.g., specific function):
Use JSON method but plug in the numbers.
- **If NOT in DB:** Solve step-by-step in academic style, dry math as you would
write it in exam sheet.
- **Style:** Dry, formal, "notebook" style. No conversational filler.
- **NEVER summarize.** No "Applying L'Hopital 6 times".
- **SHOW ALL STEPS.** Write out $f', f'', f'''$ explicitly.
- **Theorems:** Use JSON verbatim.
- **Problems:** Step-by-step derivation.
3. APPLE WATCH FORMATTING (Strict):
- **Line Width:** MAX 25-30 chars. Force line breaks often.
- **Math:** Standard LaTeX blocks `$$...$$` or inline `$..$`.
- **Structure:**
**Ex. X ([Topic])**
[Solution/Proof]
---
**Ex. Y ([Topic])**
[Solution/Proof]
4. MULTI-PAGE/TEXT HANDLING:
- If user sends a new image -> Assume it's the next page ->
Continue to solve tasks as it was continuation, don't repeat already solved tasks.
- If user types text (e.g., "proof for lagrange") -> Treat as high-priority override\
-> Answer to specific question immediately, don't repeat already solved tasks
(help or fix something that you are asked for)
- Ignore typos in text input (fuzzy match).
"""
RAGTHEORY_SYSTEM = """You help answer theoretical exam questions.
When you receive an IMAGE with exam questions:
1. Identify ALL questions/blanks to fill
2. For EACH question, use search_knowledge_base to find relevant info
3. Provide exam-ready answers
OUTPUT FORMAT:
- Number each answer matching the question number
- Answer length: match what the question expects
(1 sentence, 1-2 sentences, fill blank, list items, etc.)
- Write answers EXACTLY as they should appear on the exam sheet - ready to copy 1:1
- Use precise terminology from the course
- No explanations, no "because", no fluff - just the answer itself
- For multi-part questions (a, b, c), answer each part separately
LANGUAGE: Match the exam language (usually English for technical terms)
STYLE: Academic, precise, minimal - as if you're writing on paper with limited space
Example input:
"Stigmergy is ............"
Example output:
"1. Stigmergy is indirect communication through environment\
modification, e.g. by leaving some marks."
Example input:
"How is crossing over performed in genetic programming? (one precise sentence)"
Example output:
"3. Usually implemented as swapping randomly selected subtrees of parent trees"
"""
DEFAULT_FOLLOW_UP = (
"Based on the conversation, suggest 3 short follow-up questions "
"the user might want to ask. Each option should be under 50 characters."
@@ -120,8 +32,4 @@ Summarize VERY briefly:
Max 2-3 sentences. This is for Apple Watch display."""
PRESETS: dict[str, tuple[str, str]] = {
"exam": (EXAM_SYSTEM, EXAM_FOLLOW_UP),
"ragtheory": (RAGTHEORY_SYSTEM, EXAM_FOLLOW_UP),
"proofs": (PROOFS_SYSTEM, EXAM_FOLLOW_UP),
}
PRESETS: dict[str, tuple[str, str]] = {"exam": (EXAM_SYSTEM, EXAM_FOLLOW_UP)}

View File

@@ -51,8 +51,6 @@ async def handle_pending_generation(bot: Bot, item: dict, item_id: str) -> None:
text=item["userMessage"],
bot=bot,
convex_chat_id=item["chatId"],
images_base64=item.get("imagesBase64"),
images_media_types=item.get("imagesMediaTypes"),
)
except Exception as e: # noqa: BLE001
logger.error(f"Error processing {item_id}: {e}")

View File

@@ -7,19 +7,15 @@ from convex import ConvexClient as SyncConvexClient
class ConvexClient:
def __init__(self, url: str) -> None:
self._client = SyncConvexClient(url)
self._lock = asyncio.Lock()
async def query(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401
async with self._lock:
return await asyncio.to_thread(self._client.query, name, args or {})
return await asyncio.to_thread(self._client.query, name, args or {})
async def mutation(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401
async with self._lock:
return await asyncio.to_thread(self._client.mutation, name, args or {})
return await asyncio.to_thread(self._client.mutation, name, args or {})
async def action(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401
async with self._lock:
return await asyncio.to_thread(self._client.action, name, args or {})
return await asyncio.to_thread(self._client.action, name, args or {})
def subscribe(self, name: str, args: dict[str, Any] | None = None) -> Any: # noqa: ANN401
return self._client.subscribe(name, args or {})

View File

@@ -23,19 +23,10 @@ class Settings(BaseSettings):
log: LogSettings
convex_url: str = Field(validation_alias=AliasChoices("CONVEX_SELF_HOSTED_URL"))
convex_http_url: str = Field(
default="", validation_alias=AliasChoices("CONVEX_HTTP_URL")
)
model_config = SettingsConfigDict(
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
)
@property
def convex_http_base_url(self) -> str:
if self.convex_http_url:
return self.convex_http_url
return self.convex_url.replace("/convex", "/convex-http")
env = Settings() # ty:ignore[missing-argument]

View File

@@ -137,7 +137,8 @@ services:
- migrate
networks:
database:
command: x convex deploy
entrypoint: bunx
command: convex dev
convex-dashboard:
image: ghcr.io/get-convex/convex-dashboard:latest

View File

@@ -5,8 +5,5 @@ yarn.lock
bun.lock
bun.lockb
# Convex generated files
src/lib/convex/_generated/
# Miscellaneous
/static/

View File

@@ -5,8 +5,6 @@
"": {
"name": "frontend",
"dependencies": {
"@ai-sdk/google": "^3.0.13",
"@convex-dev/rag": "^0.7.0",
"convex": "^1.31.5",
"convex-svelte": "^0.0.12",
"marked": "^17.0.1",
@@ -37,18 +35,6 @@
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "3.0.5", "@ai-sdk/provider-utils": "4.0.9", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NgnlY73JNuooACHqUIz5uMOEWvqR1MMVbb2soGLMozLY1fgwEIF5iJFDAGa5/YArlzw2ATVU7zQu7HkR/FUjgA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.5", "@ai-sdk/provider-utils": "4.0.9" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HYCh8miS4FLxOIpjo/BmoFVMO5BuxNpHVVDQkoJotoH8ZSFftkJJGGayIxQT/Lwx9GGvVVCOQ+lCdBBAnkl1sA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.5", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.9", "", { "dependencies": { "@ai-sdk/provider": "3.0.5", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow=="],
"@convex-dev/rag": ["@convex-dev/rag@0.7.0", "", { "dependencies": { "ai": "^6.0.0" }, "peerDependencies": { "@convex-dev/workpool": "^0.3.0", "convex": "^1.24.8", "convex-helpers": "^0.1.94" } }, "sha512-hs/py/0SZ+wcKzP8LtN89HQEI2Ts0AXMUb9N3hIr70nQ/T+wBiEOG+3WI91x1JvbkV0ChWYlaiqB1KzoQHYF1A=="],
"@convex-dev/workpool": ["@convex-dev/workpool@0.3.1", "", { "peerDependencies": { "convex": "^1.24.8", "convex-helpers": "^0.1.94" } }, "sha512-uw4Mi+irhhoYA/KwaMo5wXyYJ7BbxqeaLcCZbst3t1SxPN5488rpnR0OwBcPDCmwcdQjBVHOx+q8S4GUjq0Csg=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -147,8 +133,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
"@oxc-project/types": ["@oxc-project/types@0.71.0", "", {}, "sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w=="],
@@ -301,16 +285,12 @@
"@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=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@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=="],
"ai": ["ai@6.0.49", "", { "dependencies": { "@ai-sdk/gateway": "3.0.22", "@ai-sdk/provider": "3.0.5", "@ai-sdk/provider-utils": "4.0.9", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -345,8 +325,6 @@
"convex": ["convex@1.31.5", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-E1IuJKFwMCHDToNGukBPs6c7RFaarR3t8chLF9n98TM5/Tgmj8lM6l7sKM1aJ3VwqGaB4wbeUAPY8osbCOXBhQ=="],
"convex-helpers": ["convex-helpers@0.1.111", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-0O59Ohi8HVc3+KULxSC6JHsw8cQJyc8gZ7OAfNRVX7T5Wy6LhPx3l8veYN9avKg7UiPlO7m1eBiQMHKclIyXyQ=="],
"convex-svelte": ["convex-svelte@0.0.12", "", { "dependencies": { "esm-env": "^1.2.2", "runed": "^0.31.1" }, "peerDependencies": { "convex": "^1.10.0", "svelte": "^5.0.0" } }, "sha512-sUZoYp4ZsokyvKlbbg1dWYB7MkAjZn4nNG9DnADEt9L6KTKuhhnEIt6fdLj+3GnVBUGDTssm17+7ppzFc4y7Gg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
@@ -397,8 +375,6 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -445,8 +421,6 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
@@ -629,8 +603,6 @@
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],

View File

@@ -22,11 +22,9 @@ export default defineConfig(
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
'no-undef': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{

View File

@@ -36,8 +36,6 @@
"vite": "^7.2.6"
},
"dependencies": {
"@ai-sdk/google": "^3.0.13",
"@convex-dev/rag": "^0.7.0",
"convex": "^1.31.5",
"convex-svelte": "^0.0.12",
"marked": "^17.0.1",

View File

@@ -1,184 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
showPreview?: boolean;
oncapture: (base64: string, mediaType: string) => void;
onclose: () => void;
}
let { showPreview = true, oncapture, onclose }: Props = $props();
let videoElement: HTMLVideoElement | null = $state(null);
let stream: MediaStream | null = $state(null);
let capturedImage: { base64: string; mediaType: string } | null = $state(null);
let error: string | null = $state(null);
let closed = false;
async function findUltraWideCamera(): Promise<string | null> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter((d) => d.kind === 'videoinput');
const ultraWide = videoDevices.find(
(d) => d.label.toLowerCase().includes('ultra') && d.label.toLowerCase().includes('back')
);
return ultraWide?.deviceId ?? null;
} catch {
return null;
}
}
async function startCamera() {
if (closed) return;
if (!navigator.mediaDevices?.getUserMedia) {
error = 'Camera not supported (requires HTTPS)';
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' } },
audio: false
});
const ultraWideId = await findUltraWideCamera();
if (ultraWideId) {
stream.getTracks().forEach((t) => t.stop());
stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: ultraWideId },
width: { ideal: 4032 },
height: { ideal: 3024 }
},
audio: false
});
} else {
stream.getTracks().forEach((t) => t.stop());
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 4032 },
height: { ideal: 3024 }
},
audio: false
});
}
if (videoElement && !closed) {
videoElement.srcObject = stream;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Camera access denied';
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
}
function capture() {
if (!videoElement) return;
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(videoElement, 0, 0);
const maxSize = 1920;
const scale = Math.min(maxSize / canvas.width, maxSize / canvas.height, 1);
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(canvas.width * scale);
outCanvas.height = Math.round(canvas.height * scale);
const outCtx = outCanvas.getContext('2d');
if (!outCtx) return;
outCtx.drawImage(canvas, 0, 0, outCanvas.width, outCanvas.height);
const base64 = outCanvas.toDataURL('image/jpeg', 0.65).split(',')[1];
const mediaType = 'image/jpeg';
stopCamera();
if (showPreview) {
capturedImage = { base64, mediaType };
} else {
oncapture(base64, mediaType);
}
}
function acceptCapture() {
if (capturedImage) {
oncapture(capturedImage.base64, capturedImage.mediaType);
}
}
function retake() {
capturedImage = null;
startCamera();
}
function close() {
closed = true;
stopCamera();
onclose();
}
onMount(() => {
startCamera();
return () => {
closed = true;
stopCamera();
};
});
</script>
<div class="fixed inset-0 z-50 flex flex-col bg-black" data-camera-ui>
{#if error}
<div class="flex flex-1 flex-col items-center justify-center p-4">
<p class="mb-4 text-center text-sm text-red-400">{error}</p>
<button onclick={close} class="rounded bg-neutral-700 px-4 py-2 text-sm text-white">
Close
</button>
</div>
{:else if capturedImage}
<div class="relative min-h-0 flex-1">
<button class="absolute inset-0" onclick={acceptCapture}>
<img
src="data:{capturedImage.mediaType};base64,{capturedImage.base64}"
alt="Captured"
class="h-full w-full object-contain"
/>
</button>
</div>
<div class="flex gap-4 p-4">
<button onclick={retake} class="flex-1 rounded bg-neutral-700 py-3 text-sm text-white">
Retake
</button>
<button onclick={acceptCapture} class="flex-1 rounded bg-blue-600 py-3 text-sm text-white">
Use
</button>
</div>
{:else}
<video bind:this={videoElement} autoplay playsinline muted class="h-full w-full object-cover"
></video>
<button
onclick={close}
class="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full bg-black/50 text-white"
>
×
</button>
<button
onclick={capture}
aria-label="Capture photo"
class="absolute bottom-8 left-1/2 h-16 w-16 -translate-x-1/2 rounded-full border-4 border-white bg-white/20"
></button>
{/if}
</div>

View File

@@ -1,144 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
oncomplete: (base64: string, mediaType: string, thumbnailBase64: string) => void;
oncancel: () => void;
}
let { oncomplete, oncancel }: Props = $props();
let count = $state(3);
let videoElement: HTMLVideoElement | null = $state(null);
let stream: MediaStream | null = $state(null);
let error: string | null = $state(null);
let cancelled = false;
let countdownInterval: ReturnType<typeof setInterval> | null = null;
async function startCamera() {
if (cancelled) return;
if (!navigator.mediaDevices?.getUserMedia) {
error = 'Camera not supported (requires HTTPS)';
return;
}
try {
const constraints: MediaStreamConstraints = {
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
};
stream = await navigator.mediaDevices.getUserMedia(constraints);
if (videoElement && !cancelled) {
videoElement.srcObject = stream;
startCountdown();
}
} catch (e) {
error = e instanceof Error ? e.message : 'Camera access denied';
}
}
function stopCamera() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
}
function startCountdown() {
countdownInterval = setInterval(() => {
if (cancelled) {
stopCamera();
return;
}
count--;
if (count === 0) {
if (countdownInterval) clearInterval(countdownInterval);
capture();
}
}, 1000);
}
function capture() {
if (cancelled || !videoElement) {
stopCamera();
return;
}
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
stopCamera();
oncancel();
return;
}
ctx.drawImage(videoElement, 0, 0);
const base64 = canvas.toDataURL('image/jpeg', 0.85).split(',')[1];
const mediaType = 'image/jpeg';
const thumbMaxSize = 800;
const scale = Math.min(thumbMaxSize / canvas.width, thumbMaxSize / canvas.height, 1);
const thumbCanvas = document.createElement('canvas');
thumbCanvas.width = Math.round(canvas.width * scale);
thumbCanvas.height = Math.round(canvas.height * scale);
const thumbCtx = thumbCanvas.getContext('2d');
if (thumbCtx) {
thumbCtx.drawImage(canvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
}
const thumbnailBase64 = thumbCanvas.toDataURL('image/jpeg', 0.7).split(',')[1];
stopCamera();
oncomplete(base64, mediaType, thumbnailBase64);
}
function handleCancel() {
cancelled = true;
stopCamera();
oncancel();
}
onMount(() => {
startCamera();
return () => {
cancelled = true;
stopCamera();
};
});
</script>
<div class="fixed inset-0 z-50 flex flex-col bg-black" data-camera-ui>
{#if error}
<div class="flex flex-1 flex-col items-center justify-center p-4">
<p class="mb-4 text-center text-sm text-red-400">{error}</p>
<button onclick={handleCancel} class="rounded bg-neutral-700 px-4 py-2 text-sm text-white">
Close
</button>
</div>
{:else}
<div class="relative flex-1">
<video bind:this={videoElement} autoplay playsinline muted class="h-full w-full object-cover"
></video>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-8xl font-bold text-white drop-shadow-lg">{count}</span>
</div>
</div>
<div class="p-4 text-center">
<button onclick={handleCancel} class="text-sm text-neutral-400">Cancel</button>
</div>
{/if}
</div>

View File

@@ -2,16 +2,15 @@
interface Props {
onsubmit: (message: string) => void;
disabled?: boolean;
allowEmpty?: boolean;
}
let { onsubmit, disabled = false, allowEmpty = false }: Props = $props();
let { onsubmit, disabled = false }: Props = $props();
let value = $state('');
function handleSubmit(e: Event) {
e.preventDefault();
const trimmed = value.trim();
if ((trimmed || allowEmpty) && !disabled) {
if (trimmed && !disabled) {
onsubmit(trimmed);
value = '';
}

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import type { Id } from '$lib/convex/_generated/dataModel';
interface Photo {
_id: Id<'photoDrafts'>;
mediaType: string;
}
interface Props {
photos: Photo[];
onremove: (index: number) => void;
}
let { photos, onremove }: Props = $props();
</script>
{#if photos.length > 0}
<div class="flex flex-wrap gap-1">
{#each photos as _photo, i (i)}
<button
onclick={() => onremove(i)}
class="flex items-center gap-1 rounded bg-blue-600/30 px-1.5 py-0.5 text-[8px] text-blue-300"
>
<span>photo {i + 1}</span>
<span class="text-blue-400">&times;</span>
</button>
{/each}
</div>
{/if}

View File

@@ -1,65 +0,0 @@
<script lang="ts">
interface Props {
hasCamera: boolean;
hasOnlineDevices: boolean;
ontakephoto: () => void;
onrequestphoto: () => void;
}
let { hasCamera, hasOnlineDevices, ontakephoto, onrequestphoto }: Props = $props();
let menuOpen = $state(false);
function handleClick() {
if (hasCamera && hasOnlineDevices) {
menuOpen = !menuOpen;
} else if (hasOnlineDevices) {
onrequestphoto();
} else {
ontakephoto();
}
}
function handleTakePhoto() {
menuOpen = false;
ontakephoto();
}
function handleRequestPhoto() {
menuOpen = false;
onrequestphoto();
}
function handleBackdropClick() {
menuOpen = false;
}
</script>
<div class="relative">
<button
onclick={handleClick}
class="shrink-0 rounded bg-neutral-800 px-1.5 py-0.5 text-[8px] text-neutral-400"
>
+
</button>
{#if menuOpen}
<button class="fixed inset-0 z-40" onclick={handleBackdropClick} aria-label="Close menu"
></button>
<div
class="absolute bottom-full left-0 z-50 mb-1 overflow-hidden rounded bg-neutral-800 shadow-lg"
>
<button
onclick={handleTakePhoto}
class="block w-full px-3 py-2 text-left text-[10px] whitespace-nowrap text-white hover:bg-neutral-700"
>
Take photo
</button>
<button
onclick={handleRequestPhoto}
class="block w-full px-3 py-2 text-left text-[10px] whitespace-nowrap text-white hover:bg-neutral-700"
>
Request photo
</button>
</div>
{/if}
</div>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
interface Props {
base64: string;
mediaType: string;
onaccept: () => void;
onreject: () => void;
}
let { base64, mediaType, onaccept, onreject }: Props = $props();
</script>
<div class="fixed inset-0 z-50 overflow-auto bg-black" data-camera-ui>
<button class="block min-h-full min-w-full" onclick={onaccept}>
<img
src="data:{mediaType};base64,{base64}"
alt="Preview"
class="min-h-dvh min-w-full object-cover"
/>
</button>
</div>
<button
onclick={onreject}
class="fixed top-4 right-4 z-[9999] flex h-10 w-10 items-center justify-center rounded-full bg-red-600 text-xl text-white shadow-lg"
data-camera-ui
>
×
</button>

View File

@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
onaccept: () => void;
ondecline: () => void;
}
let { onaccept, ondecline }: Props = $props();
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" data-camera-ui>
<div class="w-full max-w-xs rounded-lg bg-neutral-900 p-4">
<p class="mb-4 text-center text-sm text-white">Photo requested</p>
<div class="flex gap-3">
<button onclick={ondecline} class="flex-1 rounded bg-neutral-700 py-2 text-sm text-white">
Decline
</button>
<button onclick={onaccept} class="flex-1 rounded bg-blue-600 py-2 text-sm text-white">
Capture
</button>
</div>
</div>
</div>

View File

@@ -1,135 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
oncapture: (base64: string, mediaType: string, thumbnailBase64: string) => void;
onunpair?: () => void;
}
let { oncapture, onunpair }: Props = $props();
let videoElement: HTMLVideoElement | null = $state(null);
let stream: MediaStream | null = $state(null);
let ready = $state(false);
async function findUltraWideCamera(): Promise<string | null> {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter((d) => d.kind === 'videoinput');
const ultraWide = videoDevices.find(
(d) => d.label.toLowerCase().includes('ultra') && d.label.toLowerCase().includes('back')
);
return ultraWide?.deviceId ?? null;
} catch {
return null;
}
}
async function startCamera() {
if (!navigator.mediaDevices?.getUserMedia) return;
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' } },
audio: false
});
const ultraWideId = await findUltraWideCamera();
if (ultraWideId) {
stream.getTracks().forEach((t) => t.stop());
stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: ultraWideId },
width: { ideal: 4032 },
height: { ideal: 3024 }
},
audio: false
});
} else {
stream.getTracks().forEach((t) => t.stop());
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 4032 },
height: { ideal: 3024 }
},
audio: false
});
}
if (videoElement) {
videoElement.srcObject = stream;
await new Promise<void>((resolve) => {
if (videoElement) {
videoElement.onloadedmetadata = () => resolve();
}
});
ready = true;
}
} catch {
ready = false;
}
}
export function capture() {
if (!ready || !videoElement) return false;
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
ctx.drawImage(videoElement, 0, 0);
const maxSize = 1920;
const scale = Math.min(maxSize / canvas.width, maxSize / canvas.height, 1);
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(canvas.width * scale);
outCanvas.height = Math.round(canvas.height * scale);
const outCtx = outCanvas.getContext('2d');
if (!outCtx) return false;
outCtx.drawImage(canvas, 0, 0, outCanvas.width, outCanvas.height);
const base64 = outCanvas.toDataURL('image/jpeg', 0.65).split(',')[1];
const mediaType = 'image/jpeg';
const thumbMaxSize = 800;
const thumbScale = Math.min(thumbMaxSize / outCanvas.width, thumbMaxSize / outCanvas.height, 1);
const thumbCanvas = document.createElement('canvas');
thumbCanvas.width = Math.round(outCanvas.width * thumbScale);
thumbCanvas.height = Math.round(outCanvas.height * thumbScale);
const thumbCtx = thumbCanvas.getContext('2d');
if (thumbCtx) {
thumbCtx.drawImage(outCanvas, 0, 0, thumbCanvas.width, thumbCanvas.height);
}
const thumbnailBase64 = thumbCanvas.toDataURL('image/jpeg', 0.6).split(',')[1];
oncapture(base64, mediaType, thumbnailBase64);
return true;
}
onMount(() => {
startCamera();
return () => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
};
});
</script>
<div class="fixed inset-0 z-40 bg-black">
<video bind:this={videoElement} autoplay playsinline muted class="h-full w-full object-cover"
></video>
{#if onunpair}
<button
onclick={onunpair}
class="absolute top-4 left-4 z-10 rounded-full bg-red-600/80 px-3 py-1.5 text-xs text-white"
>
unpair
</button>
{/if}
</div>

View File

@@ -1,64 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
let stealthMode = $state(false);
let lastTap = $state({ time: 0, x: 0, y: 0 });
onMount(() => {
document.body.style.touchAction = 'manipulation';
return () => {
document.body.style.touchAction = '';
};
});
function isInCenterZone(x: number, y: number): boolean {
const w = window.innerWidth;
const h = window.innerHeight;
return x > w * 0.3 && x < w * 0.7 && y > h * 0.3 && y < h * 0.7;
}
function handleTouchEnd(e: TouchEvent) {
if (e.touches.length > 0) return;
const target = e.target as HTMLElement;
if (target?.closest('[data-camera-ui]')) return;
const touch = e.changedTouches[0];
const now = Date.now();
const x = touch.clientX;
const y = touch.clientY;
if (!isInCenterZone(x, y)) {
lastTap = { time: 0, x: 0, y: 0 };
return;
}
const timeDiff = now - lastTap.time;
const distX = Math.abs(x - lastTap.x);
const distY = Math.abs(y - lastTap.y);
if (timeDiff < 500 && distX < 50 && distY < 50) {
stealthMode = !stealthMode;
lastTap = { time: 0, x: 0, y: 0 };
e.preventDefault();
} else {
lastTap = { time: now, x, y };
}
}
</script>
<svelte:document ontouchend={handleTouchEnd} />
{#if stealthMode}
<div class="stealth-overlay" ontouchend={handleTouchEnd}></div>
{/if}
<style>
.stealth-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: #000;
touch-action: manipulation;
}
</style>

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
oncomplete: () => void;
oncancel: () => void;
}
let { oncomplete, oncancel }: Props = $props();
let count = $state(3);
onMount(() => {
const interval = setInterval(() => {
count--;
if (count === 0) {
clearInterval(interval);
oncomplete();
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black" data-camera-ui>
<span class="text-8xl font-bold text-white">{count}</span>
<button onclick={oncancel} class="mt-8 text-sm text-neutral-400">Cancel</button>
</div>

View File

@@ -47,11 +47,7 @@ export function usePollingMutation<Mutation extends FunctionReference<'mutation'
export function usePollingQuery<Query extends FunctionReference<'query'>>(
query: Query,
argsGetter: () => FunctionArgs<Query> | 'skip'
): {
data: FunctionReturnType<Query> | undefined;
error: Error | null;
isLoading: boolean;
} {
): { data: FunctionReturnType<Query> | undefined; error: Error | null; isLoading: boolean } {
const client = usePollingClient();
// eslint-disable-next-line prefer-const

View File

@@ -9,17 +9,8 @@
*/
import type * as chats from "../chats.js";
import type * as devicePairings from "../devicePairings.js";
import type * as http from "../http.js";
import type * as inject from "../inject.js";
import type * as injectConnections from "../injectConnections.js";
import type * as messages from "../messages.js";
import type * as pairingRequests from "../pairingRequests.js";
import type * as pendingGenerations from "../pendingGenerations.js";
import type * as photoDrafts from "../photoDrafts.js";
import type * as photoRequests from "../photoRequests.js";
import type * as rag from "../rag.js";
import type * as ragConnections from "../ragConnections.js";
import type * as users from "../users.js";
import type {
@@ -30,17 +21,8 @@ import type {
declare const fullApi: ApiFromModules<{
chats: typeof chats;
devicePairings: typeof devicePairings;
http: typeof http;
inject: typeof inject;
injectConnections: typeof injectConnections;
messages: typeof messages;
pairingRequests: typeof pairingRequests;
pendingGenerations: typeof pendingGenerations;
photoDrafts: typeof photoDrafts;
photoRequests: typeof photoRequests;
rag: typeof rag;
ragConnections: typeof ragConnections;
users: typeof users;
}>;
@@ -70,401 +52,4 @@ export declare const internal: FilterApi<
FunctionReference<any, "internal">
>;
export declare const components: {
rag: {
chunks: {
insert: FunctionReference<
"mutation",
"internal",
{
chunks: Array<{
content: { metadata?: Record<string, any>; text: string };
embedding: Array<number>;
searchableText?: string;
}>;
entryId: string;
startOrder: number;
},
{ status: "pending" | "ready" | "replaced" }
>;
list: FunctionReference<
"query",
"internal",
{
entryId: string;
order: "desc" | "asc";
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
metadata?: Record<string, any>;
order: number;
state: "pending" | "ready" | "replaced";
text: string;
}>;
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
splitCursor?: string | null;
}
>;
replaceChunksPage: FunctionReference<
"mutation",
"internal",
{ entryId: string; startOrder: number },
{ nextStartOrder: number; status: "pending" | "ready" | "replaced" }
>;
};
entries: {
add: FunctionReference<
"mutation",
"internal",
{
allChunks?: Array<{
content: { metadata?: Record<string, any>; text: string };
embedding: Array<number>;
searchableText?: string;
}>;
entry: {
contentHash?: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
namespaceId: string;
title?: string;
};
onComplete?: string;
},
{
created: boolean;
entryId: string;
status: "pending" | "ready" | "replaced";
}
>;
addAsync: FunctionReference<
"mutation",
"internal",
{
chunker: string;
entry: {
contentHash?: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
namespaceId: string;
title?: string;
};
onComplete?: string;
},
{ created: boolean; entryId: string; status: "pending" | "ready" }
>;
deleteAsync: FunctionReference<
"mutation",
"internal",
{ entryId: string; startOrder: number },
null
>;
deleteByKeyAsync: FunctionReference<
"mutation",
"internal",
{ beforeVersion?: number; key: string; namespaceId: string },
null
>;
deleteByKeySync: FunctionReference<
"action",
"internal",
{ key: string; namespaceId: string },
null
>;
deleteSync: FunctionReference<
"action",
"internal",
{ entryId: string },
null
>;
findByContentHash: FunctionReference<
"query",
"internal",
{
contentHash: string;
dimension: number;
filterNames: Array<string>;
key: string;
modelId: string;
namespace: string;
},
{
contentHash?: string;
entryId: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
replacedAt?: number;
status: "pending" | "ready" | "replaced";
title?: string;
} | null
>;
get: FunctionReference<
"query",
"internal",
{ entryId: string },
{
contentHash?: string;
entryId: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
replacedAt?: number;
status: "pending" | "ready" | "replaced";
title?: string;
} | null
>;
list: FunctionReference<
"query",
"internal",
{
namespaceId?: string;
order?: "desc" | "asc";
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
status: "pending" | "ready" | "replaced";
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
contentHash?: string;
entryId: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
replacedAt?: number;
status: "pending" | "ready" | "replaced";
title?: string;
}>;
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
splitCursor?: string | null;
}
>;
promoteToReady: FunctionReference<
"mutation",
"internal",
{ entryId: string },
{
replacedEntry: {
contentHash?: string;
entryId: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
replacedAt?: number;
status: "pending" | "ready" | "replaced";
title?: string;
} | null;
}
>;
};
namespaces: {
deleteNamespace: FunctionReference<
"mutation",
"internal",
{ namespaceId: string },
{
deletedNamespace: null | {
createdAt: number;
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
namespaceId: string;
status: "pending" | "ready" | "replaced";
version: number;
};
}
>;
deleteNamespaceSync: FunctionReference<
"action",
"internal",
{ namespaceId: string },
null
>;
get: FunctionReference<
"query",
"internal",
{
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
},
null | {
createdAt: number;
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
namespaceId: string;
status: "pending" | "ready" | "replaced";
version: number;
}
>;
getOrCreate: FunctionReference<
"mutation",
"internal",
{
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
onComplete?: string;
status: "pending" | "ready";
},
{ namespaceId: string; status: "pending" | "ready" }
>;
list: FunctionReference<
"query",
"internal",
{
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
status: "pending" | "ready" | "replaced";
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
createdAt: number;
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
namespaceId: string;
status: "pending" | "ready" | "replaced";
version: number;
}>;
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
splitCursor?: string | null;
}
>;
listNamespaceVersions: FunctionReference<
"query",
"internal",
{
namespace: string;
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
createdAt: number;
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
namespaceId: string;
status: "pending" | "ready" | "replaced";
version: number;
}>;
pageStatus?: "SplitRecommended" | "SplitRequired" | null;
splitCursor?: string | null;
}
>;
lookup: FunctionReference<
"query",
"internal",
{
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
},
null | string
>;
promoteToReady: FunctionReference<
"mutation",
"internal",
{ namespaceId: string },
{
replacedNamespace: null | {
createdAt: number;
dimension: number;
filterNames: Array<string>;
modelId: string;
namespace: string;
namespaceId: string;
status: "pending" | "ready" | "replaced";
version: number;
};
}
>;
};
search: {
search: FunctionReference<
"action",
"internal",
{
chunkContext?: { after: number; before: number };
embedding: Array<number>;
filters: Array<{ name: string; value: any }>;
limit: number;
modelId: string;
namespace: string;
vectorScoreThreshold?: number;
},
{
entries: Array<{
contentHash?: string;
entryId: string;
filterValues: Array<{ name: string; value: any }>;
importance: number;
key?: string;
metadata?: Record<string, any>;
replacedAt?: number;
status: "pending" | "ready" | "replaced";
title?: string;
}>;
results: Array<{
content: Array<{ metadata?: Record<string, any>; text: string }>;
entryId: string;
order: number;
score: number;
startOrder: number;
}>;
}
>;
};
};
};
export declare const components: {};

View File

@@ -43,15 +43,8 @@ export const clear = mutation({
.collect();
for (const message of messages) {
if (args.preserveImages) {
const hasLegacyImage = message.imageBase64 || message.imagesBase64?.length;
const messageImages = await ctx.db
.query('messageImages')
.withIndex('by_message_id', (q) => q.eq('messageId', message._id))
.first();
if (hasLegacyImage || messageImages) {
continue;
}
if (args.preserveImages && message.imageBase64) {
continue;
}
await ctx.db.delete(message._id);
}
@@ -80,13 +73,7 @@ export const getWithUser = query({
followUpPrompt: v.optional(v.string()),
model: v.string(),
followUpModel: v.optional(v.string()),
activeChatId: v.optional(v.id('chats')),
ragCollectionMode: v.optional(
v.object({
ragDatabaseId: v.id('ragDatabases'),
activeSince: v.number()
})
)
activeChatId: v.optional(v.id('chats'))
})
}),
v.null()

View File

@@ -1,7 +0,0 @@
import { defineApp } from 'convex/server';
import rag from '@convex-dev/rag/convex.config';
const app = defineApp();
app.use(rag);
export default app;

View File

@@ -1,109 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const register = mutation({
args: {
chatId: v.id('chats'),
deviceId: v.string(),
hasCamera: v.boolean()
},
returns: v.id('devicePairings'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const device = existing.find((d) => d.deviceId === args.deviceId);
if (device) {
await ctx.db.patch(device._id, {
hasCamera: args.hasCamera,
lastSeen: Date.now()
});
return device._id;
}
return await ctx.db.insert('devicePairings', {
chatId: args.chatId,
deviceId: args.deviceId,
hasCamera: args.hasCamera,
lastSeen: Date.now()
});
}
});
export const heartbeat = mutation({
args: {
chatId: v.id('chats'),
deviceId: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const devices = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const device = devices.find((d) => d.deviceId === args.deviceId);
if (device) {
await ctx.db.patch(device._id, { lastSeen: Date.now() });
}
return null;
}
});
export const getMyDevice = query({
args: { chatId: v.id('chats'), deviceId: v.string() },
returns: v.union(
v.object({
_id: v.id('devicePairings'),
_creationTime: v.number(),
chatId: v.id('chats'),
deviceId: v.string(),
hasCamera: v.boolean(),
pairedWithDeviceId: v.optional(v.string()),
lastSeen: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const devices = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
return devices.find((d) => d.deviceId === args.deviceId) ?? null;
}
});
export const getPairedDevice = query({
args: { chatId: v.id('chats'), deviceId: v.string() },
returns: v.union(
v.object({
_id: v.id('devicePairings'),
_creationTime: v.number(),
chatId: v.id('chats'),
deviceId: v.string(),
hasCamera: v.boolean(),
pairedWithDeviceId: v.optional(v.string()),
lastSeen: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const devices = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const myDevice = devices.find((d) => d.deviceId === args.deviceId);
if (!myDevice?.pairedWithDeviceId) return null;
const thirtySecondsAgo = Date.now() - 30000;
const paired = devices.find(
(d) => d.deviceId === myDevice.pairedWithDeviceId && d.lastSeen > thirtySecondsAgo
);
return paired ?? null;
}
});

View File

@@ -1,43 +0,0 @@
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
import { api } from './_generated/api';
import type { Id } from './_generated/dataModel';
const http = httpRouter();
http.route({
path: '/rag/search',
method: 'POST',
handler: httpAction(async (ctx, req) => {
const body = await req.json();
const { userId, dbNames, query, apiKey, limit } = body as {
userId: string;
dbNames: string[];
query: string;
apiKey: string;
limit?: number;
};
if (!userId || !dbNames || !query || !apiKey) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await ctx.runAction(api.rag.searchMultiple, {
userId: userId as Id<'users'>,
dbNames,
apiKey,
query,
limit
});
return new Response(JSON.stringify(result), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
})
});
export default http;

View File

@@ -1,109 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const createDatabase = mutation({
args: { userId: v.id('users'), name: v.string() },
returns: v.id('injectDatabases'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('injectDatabases')
.withIndex('by_user_id_and_name', (q) => q.eq('userId', args.userId).eq('name', args.name))
.unique();
if (existing) {
return existing._id;
}
return await ctx.db.insert('injectDatabases', {
userId: args.userId,
name: args.name,
createdAt: Date.now()
});
}
});
export const getDatabase = query({
args: { userId: v.id('users'), name: v.string() },
returns: v.union(
v.object({
_id: v.id('injectDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
content: v.optional(v.string()),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db
.query('injectDatabases')
.withIndex('by_user_id_and_name', (q) => q.eq('userId', args.userId).eq('name', args.name))
.unique();
}
});
export const getDatabaseById = query({
args: { injectDatabaseId: v.id('injectDatabases') },
returns: v.union(
v.object({
_id: v.id('injectDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
content: v.optional(v.string()),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.injectDatabaseId);
}
});
export const listDatabases = query({
args: { userId: v.id('users') },
returns: v.array(
v.object({
_id: v.id('injectDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
content: v.optional(v.string()),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('injectDatabases')
.withIndex('by_user_id', (q) => q.eq('userId', args.userId))
.collect();
}
});
export const setContent = mutation({
args: { injectDatabaseId: v.id('injectDatabases'), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.injectDatabaseId, { content: args.content });
return null;
}
});
export const deleteDatabase = mutation({
args: { injectDatabaseId: v.id('injectDatabases') },
returns: v.null(),
handler: async (ctx, args) => {
const connections = await ctx.db
.query('injectConnections')
.withIndex('by_inject_database_id', (q) => q.eq('injectDatabaseId', args.injectDatabaseId))
.collect();
for (const conn of connections) {
await ctx.db.delete(conn._id);
}
await ctx.db.delete(args.injectDatabaseId);
return null;
}
});

View File

@@ -1,102 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const connect = mutation({
args: {
userId: v.id('users'),
injectDatabaseId: v.id('injectDatabases'),
isGlobal: v.optional(v.boolean())
},
returns: v.id('injectConnections'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('injectConnections')
.withIndex('by_user_id_and_inject_database_id', (q) =>
q.eq('userId', args.userId).eq('injectDatabaseId', args.injectDatabaseId)
)
.unique();
if (existing) {
return existing._id;
}
return await ctx.db.insert('injectConnections', {
userId: args.userId,
injectDatabaseId: args.injectDatabaseId,
isGlobal: args.isGlobal ?? true,
createdAt: Date.now()
});
}
});
export const disconnect = mutation({
args: {
userId: v.id('users'),
injectDatabaseId: v.id('injectDatabases')
},
returns: v.boolean(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('injectConnections')
.withIndex('by_user_id_and_inject_database_id', (q) =>
q.eq('userId', args.userId).eq('injectDatabaseId', args.injectDatabaseId)
)
.unique();
if (!existing) {
return false;
}
await ctx.db.delete(existing._id);
return true;
}
});
export const getActiveForUser = query({
args: { userId: v.id('users') },
returns: v.array(
v.object({
_id: v.id('injectConnections'),
_creationTime: v.number(),
userId: v.id('users'),
injectDatabaseId: v.id('injectDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('injectConnections')
.withIndex('by_user_id', (q) => q.eq('userId', args.userId))
.collect();
}
});
export const getByInjectDatabaseId = query({
args: { injectDatabaseId: v.id('injectDatabases') },
returns: v.array(
v.object({
_id: v.id('injectConnections'),
_creationTime: v.number(),
userId: v.id('users'),
injectDatabaseId: v.id('injectDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('injectConnections')
.withIndex('by_inject_database_id', (q) => q.eq('injectDatabaseId', args.injectDatabaseId))
.collect();
}
});
export const deleteConnection = mutation({
args: { connectionId: v.id('injectConnections') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.connectionId);
return null;
}
});

View File

@@ -1,6 +1,5 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import type { Id } from './_generated/dataModel';
export const listByChat = query({
args: { chatId: v.id('chats') },
@@ -11,6 +10,8 @@ export const listByChat = query({
chatId: v.id('chats'),
role: v.union(v.literal('user'), v.literal('assistant')),
content: v.string(),
imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()),
followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(),
@@ -18,23 +19,11 @@ export const listByChat = query({
})
),
handler: async (ctx, args) => {
const messages = await ctx.db
return await ctx.db
.query('messages')
.withIndex('by_chat_id_and_created_at', (q) => q.eq('chatId', args.chatId))
.order('asc')
.collect();
return messages.map((m) => ({
_id: m._id,
_creationTime: m._creationTime,
chatId: m.chatId,
role: m.role,
content: m.content,
followUpOptions: m.followUpOptions,
source: m.source,
createdAt: m.createdAt,
isStreaming: m.isStreaming
}));
}
});
@@ -46,9 +35,6 @@ export const create = mutation({
source: v.union(v.literal('telegram'), v.literal('web')),
imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()),
imagesBase64: v.optional(v.array(v.string())),
imagesMediaTypes: v.optional(v.array(v.string())),
photoDraftIds: v.optional(v.array(v.id('photoDrafts'))),
followUpOptions: v.optional(v.array(v.string())),
isStreaming: v.optional(v.boolean())
},
@@ -61,57 +47,23 @@ export const create = mutation({
source: args.source,
imageBase64: args.imageBase64,
imageMediaType: args.imageMediaType,
imagesBase64: args.imagesBase64,
imagesMediaTypes: args.imagesMediaTypes,
followUpOptions: args.followUpOptions,
createdAt: Date.now(),
isStreaming: args.isStreaming
});
const drafts: Array<{ base64: string; mediaType: string; id: Id<'photoDrafts'> }> = [];
if (args.photoDraftIds && args.photoDraftIds.length > 0) {
for (const draftId of args.photoDraftIds) {
const draft = await ctx.db.get(draftId);
if (draft) {
drafts.push({ base64: draft.base64, mediaType: draft.mediaType, id: draft._id });
}
}
}
for (let i = 0; i < drafts.length; i++) {
await ctx.db.insert('messageImages', {
messageId,
base64: drafts[i].base64,
mediaType: drafts[i].mediaType,
order: i
});
}
if (args.source === 'web' && args.role === 'user') {
const chat = await ctx.db.get(args.chatId);
if (chat) {
const pendingGenId = await ctx.db.insert('pendingGenerations', {
await ctx.db.insert('pendingGenerations', {
userId: chat.userId,
chatId: args.chatId,
userMessage: args.content,
createdAt: Date.now()
});
for (let i = 0; i < drafts.length; i++) {
await ctx.db.insert('pendingGenerationImages', {
pendingGenerationId: pendingGenId,
base64: drafts[i].base64,
mediaType: drafts[i].mediaType,
order: i
});
}
}
}
for (const draft of drafts) {
await ctx.db.delete(draft.id);
}
return messageId;
}
});
@@ -182,8 +134,6 @@ export const getLastAssistantMessage = query({
content: v.string(),
imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()),
imagesBase64: v.optional(v.array(v.string())),
imagesMediaTypes: v.optional(v.array(v.string())),
followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(),
@@ -216,33 +166,11 @@ export const getChatImages = query({
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const images: Array<{ base64: string; mediaType: string }> = [];
for (const m of messages) {
const msgImages = await ctx.db
.query('messageImages')
.withIndex('by_message_id', (q) => q.eq('messageId', m._id))
.collect();
for (const img of msgImages.sort((a, b) => a.order - b.order)) {
images.push({ base64: img.base64, mediaType: img.mediaType });
}
if (m.imagesBase64 && m.imagesMediaTypes) {
for (let i = 0; i < m.imagesBase64.length; i++) {
images.push({
base64: m.imagesBase64[i],
mediaType: m.imagesMediaTypes[i]
});
}
} else if (m.imageBase64 && m.imageMediaType) {
images.push({
base64: m.imageBase64,
mediaType: m.imageMediaType
});
}
}
return images;
return messages
.filter((m) => m.imageBase64 && m.imageMediaType)
.map((m) => ({
base64: m.imageBase64!,
mediaType: m.imageMediaType!
}));
}
});

View File

@@ -1,122 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const create = mutation({
args: {
chatId: v.id('chats'),
fromDeviceId: v.string()
},
returns: v.id('pairingRequests'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('pairingRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const pending = existing.find(
(r) => r.fromDeviceId === args.fromDeviceId && r.status === 'pending'
);
if (pending) return pending._id;
return await ctx.db.insert('pairingRequests', {
chatId: args.chatId,
fromDeviceId: args.fromDeviceId,
status: 'pending',
createdAt: Date.now()
});
}
});
export const accept = mutation({
args: {
requestId: v.id('pairingRequests'),
acceptingDeviceId: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const request = await ctx.db.get(args.requestId);
if (!request || request.status !== 'pending') return null;
await ctx.db.patch(args.requestId, { status: 'accepted' });
const devices = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', request.chatId))
.collect();
const fromDevice = devices.find((d) => d.deviceId === request.fromDeviceId);
const acceptingDevice = devices.find((d) => d.deviceId === args.acceptingDeviceId);
if (fromDevice) {
await ctx.db.patch(fromDevice._id, { pairedWithDeviceId: args.acceptingDeviceId });
}
if (acceptingDevice) {
await ctx.db.patch(acceptingDevice._id, { pairedWithDeviceId: request.fromDeviceId });
}
return null;
}
});
export const reject = mutation({
args: { requestId: v.id('pairingRequests') },
returns: v.null(),
handler: async (ctx, args) => {
const request = await ctx.db.get(args.requestId);
if (!request || request.status !== 'pending') return null;
await ctx.db.patch(args.requestId, { status: 'rejected' });
return null;
}
});
export const getPending = query({
args: { chatId: v.id('chats'), excludeDeviceId: v.string() },
returns: v.union(
v.object({
_id: v.id('pairingRequests'),
_creationTime: v.number(),
chatId: v.id('chats'),
fromDeviceId: v.string(),
status: v.union(v.literal('pending'), v.literal('accepted'), v.literal('rejected')),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('pairingRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
return (
requests.find((r) => r.status === 'pending' && r.fromDeviceId !== args.excludeDeviceId) ??
null
);
}
});
export const unpair = mutation({
args: {
chatId: v.id('chats'),
deviceId: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const devices = await ctx.db
.query('devicePairings')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.collect();
const myDevice = devices.find((d) => d.deviceId === args.deviceId);
if (!myDevice?.pairedWithDeviceId) return null;
const pairedDevice = devices.find((d) => d.deviceId === myDevice.pairedWithDeviceId);
await ctx.db.patch(myDevice._id, { pairedWithDeviceId: undefined });
if (pairedDevice) {
await ctx.db.patch(pairedDevice._id, { pairedWithDeviceId: undefined });
}
return null;
}
});

View File

@@ -10,33 +10,11 @@ export const list = query({
userId: v.id('users'),
chatId: v.id('chats'),
userMessage: v.string(),
imagesBase64: v.optional(v.array(v.string())),
imagesMediaTypes: v.optional(v.array(v.string())),
createdAt: v.number()
})
),
handler: async (ctx) => {
const pending = await ctx.db.query('pendingGenerations').collect();
const result = [];
for (const p of pending) {
const images = await ctx.db
.query('pendingGenerationImages')
.withIndex('by_pending_generation_id', (q) => q.eq('pendingGenerationId', p._id))
.collect();
const sortedImages = images.sort((a, b) => a.order - b.order);
result.push({
...p,
imagesBase64:
sortedImages.length > 0 ? sortedImages.map((img) => img.base64) : p.imagesBase64,
imagesMediaTypes:
sortedImages.length > 0 ? sortedImages.map((img) => img.mediaType) : p.imagesMediaTypes
});
}
return result;
return await ctx.db.query('pendingGenerations').collect();
}
});
@@ -61,35 +39,7 @@ export const remove = mutation({
args: { id: v.id('pendingGenerations') },
returns: v.null(),
handler: async (ctx, args) => {
const images = await ctx.db
.query('pendingGenerationImages')
.withIndex('by_pending_generation_id', (q) => q.eq('pendingGenerationId', args.id))
.collect();
for (const img of images) {
await ctx.db.delete(img._id);
}
await ctx.db.delete(args.id);
return null;
}
});
export const getImages = query({
args: { pendingGenerationId: v.id('pendingGenerations') },
returns: v.array(
v.object({
base64: v.string(),
mediaType: v.string()
})
),
handler: async (ctx, args) => {
const images = await ctx.db
.query('pendingGenerationImages')
.withIndex('by_pending_generation_id', (q) =>
q.eq('pendingGenerationId', args.pendingGenerationId)
)
.collect();
return images
.sort((a, b) => a.order - b.order)
.map((img) => ({ base64: img.base64, mediaType: img.mediaType }));
}
});

View File

@@ -1,93 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
const photoValidator = v.object({
base64: v.string(),
mediaType: v.string()
});
export const get = query({
args: { chatId: v.id('chats'), deviceId: v.string() },
returns: v.object({
photos: v.array(
v.object({
_id: v.id('photoDrafts'),
mediaType: v.string()
})
)
}),
handler: async (ctx, args) => {
const drafts = await ctx.db
.query('photoDrafts')
.withIndex('by_chat_id_and_device_id', (q) =>
q.eq('chatId', args.chatId).eq('deviceId', args.deviceId)
)
.collect();
return {
photos: drafts.map((d) => ({
_id: d._id,
mediaType: d.mediaType
}))
};
}
});
export const addPhoto = mutation({
args: {
chatId: v.id('chats'),
deviceId: v.string(),
photo: photoValidator
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert('photoDrafts', {
chatId: args.chatId,
deviceId: args.deviceId,
base64: args.photo.base64,
mediaType: args.photo.mediaType,
createdAt: Date.now()
});
return null;
}
});
export const removePhoto = mutation({
args: {
chatId: v.id('chats'),
deviceId: v.string(),
index: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const drafts = await ctx.db
.query('photoDrafts')
.withIndex('by_chat_id_and_device_id', (q) =>
q.eq('chatId', args.chatId).eq('deviceId', args.deviceId)
)
.collect();
if (drafts[args.index]) {
await ctx.db.delete(drafts[args.index]._id);
}
return null;
}
});
export const clear = mutation({
args: { chatId: v.id('chats'), deviceId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const drafts = await ctx.db
.query('photoDrafts')
.withIndex('by_chat_id_and_device_id', (q) =>
q.eq('chatId', args.chatId).eq('deviceId', args.deviceId)
)
.collect();
for (const draft of drafts) {
await ctx.db.delete(draft._id);
}
return null;
}
});

View File

@@ -1,293 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
const photoRequestValidator = v.object({
_id: v.id('photoRequests'),
_creationTime: v.number(),
chatId: v.id('chats'),
requesterId: v.string(),
captureDeviceId: v.optional(v.string()),
status: v.union(
v.literal('pending'),
v.literal('countdown'),
v.literal('capture_now'),
v.literal('captured'),
v.literal('accepted'),
v.literal('rejected')
),
photoBase64: v.optional(v.string()),
photoMediaType: v.optional(v.string()),
thumbnailBase64: v.optional(v.string()),
createdAt: v.number()
});
const photoRequestLightValidator = v.object({
_id: v.id('photoRequests'),
status: v.union(
v.literal('pending'),
v.literal('countdown'),
v.literal('capture_now'),
v.literal('captured'),
v.literal('accepted'),
v.literal('rejected')
),
photoMediaType: v.optional(v.string()),
thumbnailBase64: v.optional(v.string())
});
export const create = mutation({
args: {
chatId: v.id('chats'),
requesterId: v.string(),
captureDeviceId: v.string()
},
returns: v.id('photoRequests'),
handler: async (ctx, args) => {
const oldRequests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.take(20);
for (const req of oldRequests) {
if (req.status === 'pending' || req.status === 'countdown' || req.status === 'capture_now') {
await ctx.db.patch(req._id, { status: 'rejected' });
}
}
return await ctx.db.insert('photoRequests', {
chatId: args.chatId,
requesterId: args.requesterId,
captureDeviceId: args.captureDeviceId,
status: 'countdown',
createdAt: Date.now()
});
}
});
export const markCaptureNow = mutation({
args: { requestId: v.id('photoRequests') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.requestId, { status: 'capture_now' });
return null;
}
});
export const submitPhoto = mutation({
args: {
requestId: v.id('photoRequests'),
photoBase64: v.string(),
photoMediaType: v.string(),
thumbnailBase64: v.optional(v.string())
},
returns: v.boolean(),
handler: async (ctx, args) => {
const req = await ctx.db.get(args.requestId);
if (!req || req.status !== 'capture_now') {
return false;
}
await ctx.db.patch(args.requestId, {
status: 'captured',
photoBase64: args.photoBase64,
photoMediaType: args.photoMediaType,
thumbnailBase64: args.thumbnailBase64
});
return true;
}
});
export const markAccepted = mutation({
args: { requestId: v.id('photoRequests') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.requestId, { status: 'accepted' });
return null;
}
});
export const markRejected = mutation({
args: { requestId: v.id('photoRequests') },
returns: v.boolean(),
handler: async (ctx, args) => {
const req = await ctx.db.get(args.requestId);
if (!req || req.status === 'accepted' || req.status === 'rejected') {
return false;
}
await ctx.db.patch(req._id, { status: 'rejected' });
return true;
}
});
const captureNowLightValidator = v.object({
_id: v.id('photoRequests'),
status: v.literal('capture_now')
});
export const getCaptureNowRequest = query({
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
returns: v.union(captureNowLightValidator, v.null()),
handler: async (ctx, args) => {
const now = Date.now();
const maxAge = 60 * 1000;
const requests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.order('desc')
.take(50);
const found = requests.find((r) => r.status === 'capture_now' && now - r.createdAt < maxAge);
if (!found) return null;
return { _id: found._id, status: 'capture_now' as const };
}
});
export const getActiveForCapture = query({
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
returns: v.union(photoRequestValidator, v.null()),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.order('desc')
.take(50);
return requests.find((r) => r.status === 'countdown' || r.status === 'capture_now') ?? null;
}
});
export const getMyActiveRequest = query({
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
returns: v.union(photoRequestLightValidator, v.null()),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.order('desc')
.take(100);
if (!args.deviceId) return null;
const found = requests.find(
(r) =>
r.requesterId === args.deviceId &&
(r.status === 'countdown' || r.status === 'capture_now' || r.status === 'captured')
);
if (!found) return null;
return {
_id: found._id,
status: found.status,
photoMediaType: found.photoMediaType,
thumbnailBase64: found.thumbnailBase64
};
}
});
export const getPhotoData = query({
args: { requestId: v.id('photoRequests') },
returns: v.union(
v.object({
photoBase64: v.string(),
photoMediaType: v.string(),
thumbnailBase64: v.optional(v.string())
}),
v.null()
),
handler: async (ctx, args) => {
const req = await ctx.db.get(args.requestId);
if (!req || !req.photoBase64 || !req.photoMediaType) return null;
return {
photoBase64: req.photoBase64,
photoMediaType: req.photoMediaType,
thumbnailBase64: req.thumbnailBase64
};
}
});
export const getPhotoPreview = query({
args: { requestId: v.id('photoRequests') },
returns: v.union(
v.object({
thumbnailBase64: v.string(),
photoMediaType: v.string()
}),
v.null()
),
handler: async (ctx, args) => {
const req = await ctx.db.get(args.requestId);
if (!req || !req.photoMediaType) return null;
return {
thumbnailBase64: req.thumbnailBase64 || req.photoBase64 || '',
photoMediaType: req.photoMediaType
};
}
});
export const acceptPhotoToDraft = mutation({
args: {
requestId: v.id('photoRequests'),
chatId: v.id('chats'),
deviceId: v.string()
},
returns: v.id('photoDrafts'),
handler: async (ctx, args) => {
const req = await ctx.db.get(args.requestId);
if (!req || !req.photoBase64 || !req.photoMediaType) {
throw new Error('Photo request not found or has no photo');
}
const draftId = await ctx.db.insert('photoDrafts', {
chatId: args.chatId,
deviceId: args.deviceId,
base64: req.photoBase64,
mediaType: req.photoMediaType,
createdAt: Date.now()
});
await ctx.db.patch(args.requestId, { status: 'accepted' });
return draftId;
}
});
export const cleanup = mutation({
args: { chatId: v.id('chats') },
returns: v.number(),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.take(20);
let deleted = 0;
for (const req of requests) {
await ctx.db.delete(req._id);
deleted++;
}
return deleted;
}
});
export const getCapturedForPhone = query({
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
returns: v.union(photoRequestValidator, v.null()),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
.withIndex('by_chat_id', (q) => q.eq('chatId', args.chatId))
.order('desc')
.take(50);
if (!args.deviceId) return null;
return (
requests.find((r) => r.captureDeviceId === args.deviceId && r.status === 'captured') ?? null
);
}
});

View File

@@ -1,252 +0,0 @@
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { RAG } from '@convex-dev/rag';
import { v } from 'convex/values';
import { api, components } from './_generated/api';
import { action, mutation, query } from './_generated/server';
function createRagInstance(apiKey: string) {
const google = createGoogleGenerativeAI({ apiKey });
return new RAG(components.rag, {
textEmbeddingModel: google.embedding('gemini-embedding-001'),
embeddingDimension: 768
});
}
function buildNamespace(userId: string, dbName: string): string {
return `user_${userId}/${dbName}`;
}
export const createDatabase = mutation({
args: { userId: v.id('users'), name: v.string() },
returns: v.id('ragDatabases'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('ragDatabases')
.withIndex('by_user_id_and_name', (q) => q.eq('userId', args.userId).eq('name', args.name))
.unique();
if (existing) {
return existing._id;
}
return await ctx.db.insert('ragDatabases', {
userId: args.userId,
name: args.name,
createdAt: Date.now()
});
}
});
export const getDatabase = query({
args: { userId: v.id('users'), name: v.string() },
returns: v.union(
v.object({
_id: v.id('ragDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db
.query('ragDatabases')
.withIndex('by_user_id_and_name', (q) => q.eq('userId', args.userId).eq('name', args.name))
.unique();
}
});
export const getDatabaseById = query({
args: { ragDatabaseId: v.id('ragDatabases') },
returns: v.union(
v.object({
_id: v.id('ragDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.ragDatabaseId);
}
});
export const listDatabases = query({
args: { userId: v.id('users') },
returns: v.array(
v.object({
_id: v.id('ragDatabases'),
_creationTime: v.number(),
userId: v.id('users'),
name: v.string(),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('ragDatabases')
.withIndex('by_user_id', (q) => q.eq('userId', args.userId))
.collect();
}
});
export const deleteDatabase = action({
args: { userId: v.id('users'), name: v.string(), apiKey: v.string() },
returns: v.boolean(),
handler: async (ctx, args) => {
const db = await ctx.runQuery(api.rag.getDatabase, {
userId: args.userId,
name: args.name
});
if (!db) {
return false;
}
const connections = await ctx.runQuery(api.ragConnections.getByRagDatabaseId, {
ragDatabaseId: db._id
});
for (const conn of connections) {
await ctx.runMutation(api.ragConnections.deleteConnection, {
connectionId: conn._id
});
}
await ctx.runMutation(api.rag.deleteDatabaseRecord, {
ragDatabaseId: db._id
});
return true;
}
});
export const deleteDatabaseRecord = mutation({
args: { ragDatabaseId: v.id('ragDatabases') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.ragDatabaseId);
return null;
}
});
export const addContent = action({
args: {
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases'),
apiKey: v.string(),
text: v.string(),
key: v.optional(v.string())
},
returns: v.null(),
handler: async (ctx, args) => {
const db = await ctx.runQuery(api.rag.getDatabaseById, {
ragDatabaseId: args.ragDatabaseId
});
if (!db) {
throw new Error('RAG database not found');
}
const rag = createRagInstance(args.apiKey);
const namespace = buildNamespace(args.userId, db.name);
await rag.add(ctx, {
namespace,
text: args.text,
key: args.key
});
return null;
}
});
export const search = action({
args: {
userId: v.id('users'),
dbName: v.string(),
apiKey: v.string(),
query: v.string(),
limit: v.optional(v.number())
},
returns: v.object({
text: v.string(),
results: v.array(
v.object({
text: v.string(),
score: v.number()
})
)
}),
handler: async (ctx, args) => {
const rag = createRagInstance(args.apiKey);
const namespace = buildNamespace(args.userId, args.dbName);
const { results, text } = await rag.search(ctx, {
namespace,
query: args.query,
limit: args.limit ?? 5
});
return {
text: text ?? '',
results: results.map((r) => ({
text: r.content.map((c) => c.text).join('\n'),
score: r.score
}))
};
}
});
export const searchMultiple = action({
args: {
userId: v.id('users'),
dbNames: v.array(v.string()),
apiKey: v.string(),
query: v.string(),
limit: v.optional(v.number())
},
returns: v.object({
text: v.string(),
results: v.array(
v.object({
text: v.string(),
score: v.number(),
dbName: v.string()
})
)
}),
handler: async (ctx, args) => {
const rag = createRagInstance(args.apiKey);
const allResults: Array<{ text: string; score: number; dbName: string }> = [];
for (const dbName of args.dbNames) {
const namespace = buildNamespace(args.userId, dbName);
const { results } = await rag.search(ctx, {
namespace,
query: args.query,
limit: args.limit ?? 5
});
for (const r of results) {
allResults.push({
text: r.content.map((c) => c.text).join('\n'),
score: r.score,
dbName
});
}
}
allResults.sort((a, b) => b.score - a.score);
const topResults = allResults.slice(0, args.limit ?? 5);
const combinedText = topResults.map((r) => r.text).join('\n\n---\n\n');
return {
text: combinedText,
results: topResults
};
}
});

View File

@@ -1,102 +0,0 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
export const connect = mutation({
args: {
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases'),
isGlobal: v.optional(v.boolean())
},
returns: v.id('ragConnections'),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('ragConnections')
.withIndex('by_user_id_and_rag_database_id', (q) =>
q.eq('userId', args.userId).eq('ragDatabaseId', args.ragDatabaseId)
)
.unique();
if (existing) {
return existing._id;
}
return await ctx.db.insert('ragConnections', {
userId: args.userId,
ragDatabaseId: args.ragDatabaseId,
isGlobal: args.isGlobal ?? true,
createdAt: Date.now()
});
}
});
export const disconnect = mutation({
args: {
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases')
},
returns: v.boolean(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query('ragConnections')
.withIndex('by_user_id_and_rag_database_id', (q) =>
q.eq('userId', args.userId).eq('ragDatabaseId', args.ragDatabaseId)
)
.unique();
if (!existing) {
return false;
}
await ctx.db.delete(existing._id);
return true;
}
});
export const getActiveForUser = query({
args: { userId: v.id('users') },
returns: v.array(
v.object({
_id: v.id('ragConnections'),
_creationTime: v.number(),
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('ragConnections')
.withIndex('by_user_id', (q) => q.eq('userId', args.userId))
.collect();
}
});
export const getByRagDatabaseId = query({
args: { ragDatabaseId: v.id('ragDatabases') },
returns: v.array(
v.object({
_id: v.id('ragConnections'),
_creationTime: v.number(),
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
return await ctx.db
.query('ragConnections')
.withIndex('by_rag_database_id', (q) => q.eq('ragDatabaseId', args.ragDatabaseId))
.collect();
}
});
export const deleteConnection = mutation({
args: { connectionId: v.id('ragConnections') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.connectionId);
return null;
}
});

View File

@@ -10,19 +10,7 @@ export default defineSchema({
followUpPrompt: v.optional(v.string()),
model: v.string(),
followUpModel: v.optional(v.string()),
activeChatId: v.optional(v.id('chats')),
ragCollectionMode: v.optional(
v.object({
ragDatabaseId: v.id('ragDatabases'),
activeSince: v.number()
})
),
injectCollectionMode: v.optional(
v.object({
injectDatabaseId: v.id('injectDatabases'),
activeSince: v.number()
})
)
activeChatId: v.optional(v.id('chats'))
}).index('by_telegram_id', ['telegramId']),
chats: defineTable({
@@ -38,8 +26,6 @@ export default defineSchema({
imageBase64: v.optional(v.string()),
imageMediaType: v.optional(v.string()),
imageStorageId: v.optional(v.id('_storage')),
imagesBase64: v.optional(v.array(v.string())),
imagesMediaTypes: v.optional(v.array(v.string())),
followUpOptions: v.optional(v.array(v.string())),
source: v.union(v.literal('telegram'), v.literal('web')),
createdAt: v.number(),
@@ -52,100 +38,6 @@ export default defineSchema({
userId: v.id('users'),
chatId: v.id('chats'),
userMessage: v.string(),
imagesBase64: v.optional(v.array(v.string())),
imagesMediaTypes: v.optional(v.array(v.string())),
createdAt: v.number()
}),
pendingGenerationImages: defineTable({
pendingGenerationId: v.id('pendingGenerations'),
base64: v.string(),
mediaType: v.string(),
order: v.number()
}).index('by_pending_generation_id', ['pendingGenerationId']),
messageImages: defineTable({
messageId: v.id('messages'),
base64: v.string(),
mediaType: v.string(),
order: v.number()
}).index('by_message_id', ['messageId']),
devicePairings: defineTable({
chatId: v.id('chats'),
deviceId: v.string(),
hasCamera: v.boolean(),
pairedWithDeviceId: v.optional(v.string()),
lastSeen: v.number()
}).index('by_chat_id', ['chatId']),
pairingRequests: defineTable({
chatId: v.id('chats'),
fromDeviceId: v.string(),
status: v.union(v.literal('pending'), v.literal('accepted'), v.literal('rejected')),
createdAt: v.number()
}).index('by_chat_id', ['chatId']),
photoRequests: defineTable({
chatId: v.id('chats'),
requesterId: v.string(),
captureDeviceId: v.optional(v.string()),
status: v.union(
v.literal('pending'),
v.literal('countdown'),
v.literal('capture_now'),
v.literal('captured'),
v.literal('accepted'),
v.literal('rejected')
),
photoBase64: v.optional(v.string()),
photoMediaType: v.optional(v.string()),
thumbnailBase64: v.optional(v.string()),
createdAt: v.number()
}).index('by_chat_id', ['chatId']),
photoDrafts: defineTable({
chatId: v.id('chats'),
deviceId: v.string(),
base64: v.string(),
mediaType: v.string(),
createdAt: v.number()
}).index('by_chat_id_and_device_id', ['chatId', 'deviceId']),
ragDatabases: defineTable({
userId: v.id('users'),
name: v.string(),
createdAt: v.number()
})
.index('by_user_id', ['userId'])
.index('by_user_id_and_name', ['userId', 'name']),
ragConnections: defineTable({
userId: v.id('users'),
ragDatabaseId: v.id('ragDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
.index('by_user_id', ['userId'])
.index('by_user_id_and_rag_database_id', ['userId', 'ragDatabaseId'])
.index('by_rag_database_id', ['ragDatabaseId']),
injectDatabases: defineTable({
userId: v.id('users'),
name: v.string(),
content: v.optional(v.string()),
createdAt: v.number()
})
.index('by_user_id', ['userId'])
.index('by_user_id_and_name', ['userId', 'name']),
injectConnections: defineTable({
userId: v.id('users'),
injectDatabaseId: v.id('injectDatabases'),
isGlobal: v.boolean(),
createdAt: v.number()
})
.index('by_user_id', ['userId'])
.index('by_user_id_and_inject_database_id', ['userId', 'injectDatabaseId'])
.index('by_inject_database_id', ['injectDatabaseId'])
});

View File

@@ -16,19 +16,7 @@ export const getById = query({
followUpPrompt: v.optional(v.string()),
model: v.string(),
followUpModel: v.optional(v.string()),
activeChatId: v.optional(v.id('chats')),
ragCollectionMode: v.optional(
v.object({
ragDatabaseId: v.id('ragDatabases'),
activeSince: v.number()
})
),
injectCollectionMode: v.optional(
v.object({
injectDatabaseId: v.id('injectDatabases'),
activeSince: v.number()
})
)
activeChatId: v.optional(v.id('chats'))
}),
v.null()
),
@@ -50,19 +38,7 @@ export const getByTelegramId = query({
followUpPrompt: v.optional(v.string()),
model: v.string(),
followUpModel: v.optional(v.string()),
activeChatId: v.optional(v.id('chats')),
ragCollectionMode: v.optional(
v.object({
ragDatabaseId: v.id('ragDatabases'),
activeSince: v.number()
})
),
injectCollectionMode: v.optional(
v.object({
injectDatabaseId: v.id('injectDatabases'),
activeSince: v.number()
})
)
activeChatId: v.optional(v.id('chats'))
}),
v.null()
),
@@ -151,79 +127,3 @@ export const setActiveChat = mutation({
return null;
}
});
export const startRagCollectionMode = mutation({
args: { userId: v.id('users'), ragDatabaseId: v.id('ragDatabases') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
ragCollectionMode: {
ragDatabaseId: args.ragDatabaseId,
activeSince: Date.now()
}
});
return null;
}
});
export const stopRagCollectionMode = mutation({
args: { userId: v.id('users') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { ragCollectionMode: undefined });
return null;
}
});
export const getRagCollectionMode = query({
args: { userId: v.id('users') },
returns: v.union(
v.object({
ragDatabaseId: v.id('ragDatabases'),
activeSince: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return user?.ragCollectionMode ?? null;
}
});
export const startInjectCollectionMode = mutation({
args: { userId: v.id('users'), injectDatabaseId: v.id('injectDatabases') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
injectCollectionMode: {
injectDatabaseId: args.injectDatabaseId,
activeSince: Date.now()
}
});
return null;
}
});
export const stopInjectCollectionMode = mutation({
args: { userId: v.id('users') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { injectCollectionMode: undefined });
return null;
}
});
export const getInjectCollectionMode = query({
args: { userId: v.id('users') },
returns: v.union(
v.object({
injectDatabaseId: v.id('injectDatabases'),
activeSince: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
return user?.injectCollectionMode ?? null;
}
});

View File

@@ -2,15 +2,13 @@
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { env } from '$env/dynamic/public';
import { setupConvex } from 'convex-svelte';
import { hasWebSocketSupport, setupPollingConvex } from '$lib/convex-polling.svelte';
import { setContext } from 'svelte';
let { children } = $props();
const fallbackEnabled = env.PUBLIC_FALLBACK === '1';
const usePolling = fallbackEnabled && !hasWebSocketSupport();
const usePolling = !hasWebSocketSupport();
setContext('convex-use-polling', usePolling);
if (usePolling) {

View File

@@ -1,103 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import { browser } from '$app/environment';
import { getContext, onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { getContext } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import {
usePollingQuery,
usePollingMutation,
usePollingClient
} from '$lib/convex-polling.svelte';
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
import { api } from '$lib/convex/_generated/api';
import type { Id } from '$lib/convex/_generated/dataModel';
import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatInput from '$lib/components/ChatInput.svelte';
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
import StealthOverlay from '$lib/components/StealthOverlay.svelte';
import CameraCapture from '$lib/components/CameraCapture.svelte';
import WatchCountdown from '$lib/components/WatchCountdown.svelte';
import PhotoPreview from '$lib/components/PhotoPreview.svelte';
import DraftBadge from '$lib/components/DraftBadge.svelte';
import SilentCapture from '$lib/components/SilentCapture.svelte';
const usePolling = getContext<boolean>('convex-use-polling') ?? false;
let mnemonic = $derived(page.params.mnemonic);
let lastMessageElement: HTMLDivElement | null = $state(null);
let showScrollButton = $state(false);
let deviceId = $state('');
let hasCamera = $state(false);
let showCamera = $state(false);
let showWatchCountdown = $state(false);
let activeRequestId: Id<'photoRequests'> | null = $state(null);
let previewPhoto: {
thumbnail: string;
mediaType: string;
requestId: Id<'photoRequests'>;
} | null = $state(null);
let shownPreviewIds = new SvelteSet<string>();
let silentCaptureRef: SilentCapture | null = $state(null);
let processedCaptureNowIds = new SvelteSet<string>();
function generateId(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getOrCreateDeviceId(): string {
if (!browser) return '';
let id = localStorage.getItem('stealth-device-id');
if (!id) {
id = generateId();
localStorage.setItem('stealth-device-id', id);
}
return id;
}
async function checkCamera(): Promise<boolean> {
if (!browser) return false;
if (!navigator.mediaDevices?.enumerateDevices) return false;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((d) => d.kind === 'videoinput');
} catch {
return false;
}
}
onMount(() => {
deviceId = getOrCreateDeviceId();
checkCamera().then((has) => {
hasCamera = has;
});
});
$effect(() => {
if (!lastMessageElement) return;
const observer = new IntersectionObserver(
([entry]) => {
showScrollButton = !entry.isIntersecting;
},
{ threshold: 0, rootMargin: '0px 0px -90% 0px' }
);
observer.observe(lastMessageElement);
return () => observer.disconnect();
});
function scrollToLastMessage() {
lastMessageElement?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
const chatDataWs = usePolling
? null
: useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
@@ -106,13 +19,15 @@
: null;
const chatData = $derived(usePolling ? chatDataPoll! : chatDataWs!);
const chatId = $derived(chatData.data?.chat?._id);
const messagesQueryWs = usePolling
? null
: useQuery(api.messages.listByChat, () => (chatId ? { chatId } : 'skip'));
: useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
const messagesQueryPoll = usePolling
? usePollingQuery(api.messages.listByChat, () => (chatId ? { chatId } : 'skip'))
? usePollingQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
)
: null;
const messagesQuery = $derived(usePolling ? messagesQueryPoll! : messagesQueryWs!);
@@ -124,110 +39,6 @@
: []
);
const myDeviceWs = usePolling
? null
: useQuery(api.devicePairings.getMyDevice, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
);
const myDevicePoll = usePolling
? usePollingQuery(api.devicePairings.getMyDevice, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
)
: null;
const myDevice = $derived(usePolling ? myDevicePoll! : myDeviceWs!);
const pairedDeviceWs = usePolling
? null
: useQuery(api.devicePairings.getPairedDevice, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
);
const pairedDevicePoll = usePolling
? usePollingQuery(api.devicePairings.getPairedDevice, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
)
: null;
const pairedDevice = $derived(usePolling ? pairedDevicePoll! : pairedDeviceWs!);
const isPaired = $derived(!!myDevice.data?.pairedWithDeviceId && !!pairedDevice.data);
const pendingPairingWs = usePolling
? null
: useQuery(api.pairingRequests.getPending, () =>
chatId && deviceId ? { chatId, excludeDeviceId: deviceId } : 'skip'
);
const pendingPairingPoll = usePolling
? usePollingQuery(api.pairingRequests.getPending, () =>
chatId && deviceId ? { chatId, excludeDeviceId: deviceId } : 'skip'
)
: null;
const pendingPairing = $derived(usePolling ? pendingPairingPoll! : pendingPairingWs!);
const captureNowRequestWs = usePolling
? null
: useQuery(api.photoRequests.getCaptureNowRequest, () => (chatId ? { chatId } : 'skip'));
const captureNowRequestPoll = usePolling
? usePollingQuery(api.photoRequests.getCaptureNowRequest, () => (chatId ? { chatId } : 'skip'))
: null;
const captureNowRequest = $derived(usePolling ? captureNowRequestPoll! : captureNowRequestWs!);
const myActiveRequestWs = usePolling
? null
: useQuery(api.photoRequests.getMyActiveRequest, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
);
const myActiveRequestPoll = usePolling
? usePollingQuery(api.photoRequests.getMyActiveRequest, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
)
: null;
const myActiveRequest = $derived(usePolling ? myActiveRequestPoll! : myActiveRequestWs!);
const photoDraftWs = usePolling
? null
: useQuery(api.photoDrafts.get, () => (chatId && deviceId ? { chatId, deviceId } : 'skip'));
const photoDraftPoll = usePolling
? usePollingQuery(api.photoDrafts.get, () =>
chatId && deviceId ? { chatId, deviceId } : 'skip'
)
: null;
const photoDraft = $derived(usePolling ? photoDraftPoll! : photoDraftWs!);
const draftPhotos = $derived(photoDraft.data?.photos ?? []);
$effect(() => {
const req = captureNowRequest.data;
if (req && hasCamera && !processedCaptureNowIds.has(req._id)) {
processedCaptureNowIds.add(req._id);
const tryCapture = () => {
const success = silentCaptureRef?.capture();
if (!success) {
setTimeout(tryCapture, 100);
}
};
tryCapture();
}
});
$effect(() => {
const req = myActiveRequest.data;
if (req?.status === 'captured' && req.photoMediaType) {
if (shownPreviewIds.has(req._id)) return;
shownPreviewIds.add(req._id);
const client = pollingClient ?? clientWs;
if (client) {
client.query(api.photoRequests.getPhotoPreview, { requestId: req._id }).then((data) => {
if (data) {
previewPhoto = {
thumbnail: data.thumbnailBase64,
mediaType: data.photoMediaType,
requestId: req._id
};
}
});
}
}
});
let prevMessageCount = 0;
let prevLastMessageId: string | undefined;
@@ -242,93 +53,42 @@
});
const clientWs = usePolling ? null : useConvexClient();
const pollingClient = usePolling ? usePollingClient() : null;
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
const registerDevicePoll = usePolling ? usePollingMutation(api.devicePairings.register) : null;
const heartbeatPoll = usePolling ? usePollingMutation(api.devicePairings.heartbeat) : null;
const addPhotoPoll = usePolling ? usePollingMutation(api.photoDrafts.addPhoto) : null;
const removePhotoPoll = usePolling ? usePollingMutation(api.photoDrafts.removePhoto) : null;
const createPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.create) : null;
const acceptPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.accept) : null;
const rejectPairingPoll = usePolling ? usePollingMutation(api.pairingRequests.reject) : null;
const unpairPoll = usePolling ? usePollingMutation(api.pairingRequests.unpair) : null;
const createRequestPoll = usePolling ? usePollingMutation(api.photoRequests.create) : null;
const markCaptureNowPoll = usePolling
? usePollingMutation(api.photoRequests.markCaptureNow)
: null;
const submitPhotoPoll = usePolling ? usePollingMutation(api.photoRequests.submitPhoto) : null;
const markRejectedPoll = usePolling ? usePollingMutation(api.photoRequests.markRejected) : null;
const acceptPhotoToDraftPoll = usePolling
? usePollingMutation(api.photoRequests.acceptPhotoToDraft)
: null;
$effect(() => {
if (!chatId || !deviceId) return;
if (usePolling && registerDevicePoll) {
registerDevicePoll({ chatId, deviceId, hasCamera });
} else if (clientWs) {
clientWs.mutation(api.devicePairings.register, {
chatId,
deviceId,
hasCamera
});
}
const interval = setInterval(() => {
if (usePolling && heartbeatPoll) {
heartbeatPoll({ chatId, deviceId });
} else if (clientWs) {
clientWs.mutation(api.devicePairings.heartbeat, { chatId, deviceId });
}
}, 10000);
return () => clearInterval(interval);
});
function sendMessage(content: string) {
async function sendMessage(content: string) {
const chat = chatData.data?.chat;
if (!chat) return;
const photos = draftPhotos;
const photoDraftIds = photos.length > 0 ? photos.map((p) => p._id) : undefined;
const messageContent =
content || (photos.length > 0 ? 'Process images according to your task' : '');
if (!messageContent) return;
if (usePolling && createMessagePoll) {
createMessagePoll({
await createMessagePoll({
chatId: chat._id,
role: 'user',
content: messageContent,
source: 'web',
photoDraftIds
content,
source: 'web'
});
} else if (clientWs) {
clientWs.mutation(api.messages.create, {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: messageContent,
source: 'web',
photoDraftIds
content,
source: 'web'
});
}
}
function summarize() {
async function summarize() {
const chat = chatData.data?.chat;
if (!chat) return;
if (usePolling && createMessagePoll) {
createMessagePoll({
await createMessagePoll({
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
} else if (clientWs) {
clientWs.mutation(api.messages.create, {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
@@ -336,191 +96,6 @@
});
}
}
function handleTakePhoto() {
showCamera = true;
}
function handleCameraCapture(base64: string, mediaType: string) {
showCamera = false;
if (!chatId) return;
if (usePolling && addPhotoPoll) {
addPhotoPoll({ chatId, deviceId, photo: { base64, mediaType } });
} else if (clientWs) {
clientWs.mutation(api.photoDrafts.addPhoto, {
chatId,
deviceId,
photo: { base64, mediaType }
});
}
}
function handleCameraClose() {
showCamera = false;
}
function handlePair() {
if (!chatId) return;
if (usePolling && createPairingPoll) {
createPairingPoll({ chatId, fromDeviceId: deviceId });
} else if (clientWs) {
clientWs.mutation(api.pairingRequests.create, {
chatId,
fromDeviceId: deviceId
});
}
}
function handleAcceptPairing() {
const req = pendingPairing.data;
if (!req) return;
if (usePolling && acceptPairingPoll) {
acceptPairingPoll({ requestId: req._id, acceptingDeviceId: deviceId });
} else if (clientWs) {
clientWs.mutation(api.pairingRequests.accept, {
requestId: req._id,
acceptingDeviceId: deviceId
});
}
}
function handleRejectPairing() {
const req = pendingPairing.data;
if (!req) return;
if (usePolling && rejectPairingPoll) {
rejectPairingPoll({ requestId: req._id });
} else if (clientWs) {
clientWs.mutation(api.pairingRequests.reject, { requestId: req._id });
}
}
function handleUnpair() {
if (!chatId) return;
if (usePolling && unpairPoll) {
unpairPoll({ chatId, deviceId });
} else if (clientWs) {
clientWs.mutation(api.pairingRequests.unpair, {
chatId,
deviceId
});
}
}
function handleRequestPhoto() {
if (!chatId || !pairedDevice.data) return;
const captureDeviceId = pairedDevice.data.deviceId;
if (usePolling && createRequestPoll) {
createRequestPoll({ chatId, requesterId: deviceId, captureDeviceId }).then((id) => {
if (id) {
activeRequestId = id as Id<'photoRequests'>;
showWatchCountdown = true;
}
});
} else if (clientWs) {
clientWs
.mutation(api.photoRequests.create, {
chatId,
requesterId: deviceId,
captureDeviceId
})
.then((id) => {
activeRequestId = id;
showWatchCountdown = true;
});
}
}
function handleWatchCountdownComplete() {
showWatchCountdown = false;
if (!activeRequestId) return;
const reqId = activeRequestId;
activeRequestId = null;
if (usePolling && markCaptureNowPoll) {
markCaptureNowPoll({ requestId: reqId });
} else if (clientWs) {
clientWs.mutation(api.photoRequests.markCaptureNow, { requestId: reqId });
}
}
function handleWatchCountdownCancel() {
showWatchCountdown = false;
if (activeRequestId && markRejectedPoll) {
if (usePolling) {
markRejectedPoll({ requestId: activeRequestId });
} else if (clientWs) {
clientWs.mutation(api.photoRequests.markRejected, { requestId: activeRequestId });
}
}
activeRequestId = null;
}
function handleSilentCapture(base64: string, mediaType: string, thumbnailBase64: string) {
const req = captureNowRequest.data;
if (!req) return;
if (usePolling && submitPhotoPoll) {
submitPhotoPoll({
requestId: req._id,
photoBase64: base64,
photoMediaType: mediaType,
thumbnailBase64
});
} else if (clientWs) {
clientWs.mutation(api.photoRequests.submitPhoto, {
requestId: req._id,
photoBase64: base64,
photoMediaType: mediaType,
thumbnailBase64
});
}
}
function handlePreviewAccept() {
if (!previewPhoto || !chatId) return;
const reqId = previewPhoto.requestId;
previewPhoto = null;
if (usePolling && acceptPhotoToDraftPoll) {
acceptPhotoToDraftPoll({ requestId: reqId, chatId, deviceId });
} else if (clientWs) {
clientWs.mutation(api.photoRequests.acceptPhotoToDraft, {
requestId: reqId,
chatId,
deviceId
});
}
}
function handlePreviewReject() {
if (!previewPhoto) return;
const reqId = previewPhoto.requestId;
previewPhoto = null;
if (usePolling && markRejectedPoll) {
markRejectedPoll({ requestId: reqId });
} else if (clientWs) {
clientWs.mutation(api.photoRequests.markRejected, {
requestId: reqId
});
}
}
function handleRemoveDraftPhoto(index: number) {
if (!chatId) return;
if (usePolling && removePhotoPoll) {
removePhotoPoll({ chatId, deviceId, index });
} else if (clientWs) {
clientWs.mutation(api.photoDrafts.removePhoto, {
chatId,
deviceId,
index
});
}
}
</script>
<svelte:head>
@@ -543,22 +118,12 @@
<div class="py-4 text-center text-xs text-neutral-500">Not found</div>
{:else}
<div class="space-y-1">
{#each messages as message, i (message._id)}
{#if i === messages.length - 1}
<div bind:this={lastMessageElement}>
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
</div>
{:else}
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
{/if}
{#each messages as message (message._id)}
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
{/each}
</div>
@@ -568,111 +133,16 @@
</div>
{/if}
<div class="mt-3 space-y-2">
<div class="flex gap-2">
{#if hasCamera}
<button
onclick={handleTakePhoto}
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
>
+ photo
</button>
{/if}
{#if isPaired && pairedDevice.data?.hasCamera}
<button
onclick={handleRequestPhoto}
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
>
request
</button>
{/if}
{#if isPaired}
<button
onclick={handleUnpair}
class="flex-1 rounded bg-red-900/50 py-2 text-xs text-red-300"
>
unpair
</button>
{:else}
<button
onclick={handlePair}
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
>
pair
</button>
{/if}
<button
onclick={summarize}
class="flex-1 rounded bg-neutral-800 py-2 text-xs text-neutral-300"
>
/sum
</button>
</div>
{#if draftPhotos.length > 0}
<DraftBadge photos={draftPhotos} onremove={handleRemoveDraftPhoto} />
{/if}
<ChatInput onsubmit={sendMessage} allowEmpty={draftPhotos.length > 0} />
</div>
{/if}
{#if showScrollButton}
<button
onclick={scrollToLastMessage}
class="fixed right-3 bottom-12 z-50 flex h-8 w-8 animate-pulse items-center justify-center rounded-full bg-blue-600 text-white shadow-lg"
>
</button>
{/if}
<StealthOverlay />
{#if showCamera}
<CameraCapture oncapture={handleCameraCapture} onclose={handleCameraClose} />
{/if}
{#if pendingPairing.data && !isPaired}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/90" data-camera-ui>
<div class="rounded-lg bg-neutral-900 p-6 text-center">
<p class="mb-4 text-sm text-white">Accept pairing request?</p>
<div class="flex gap-3">
<button
onclick={handleAcceptPairing}
class="flex-1 rounded bg-blue-600 py-2 text-sm text-white"
>
Accept
</button>
<button
onclick={handleRejectPairing}
class="flex-1 rounded bg-neutral-700 py-2 text-sm text-white"
>
Reject
</button>
</div>
<div class="mt-2 flex gap-1">
<button
onclick={summarize}
class="shrink-0 rounded bg-neutral-800 px-1.5 py-0.5 text-[8px] text-neutral-400"
>
/sum
</button>
<div class="flex-1">
<ChatInput onsubmit={sendMessage} />
</div>
</div>
{/if}
{#if showWatchCountdown}
<WatchCountdown
oncomplete={handleWatchCountdownComplete}
oncancel={handleWatchCountdownCancel}
/>
{/if}
{#if previewPhoto}
<PhotoPreview
base64={previewPhoto.thumbnail}
mediaType={previewPhoto.mediaType}
onaccept={handlePreviewAccept}
onreject={handlePreviewReject}
/>
{/if}
{#if hasCamera && isPaired}
<SilentCapture
bind:this={silentCaptureRef}
oncapture={handleSilentCapture}
onunpair={handleUnpair}
/>
{/if}
</div>

View File

@@ -2,7 +2,4 @@ import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: { allowedHosts: ['parameters-detection-adware-christ.trycloudflare.com'] }
});
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });