feat(frontend): optimizations for slow internet situations

This commit is contained in:
h
2026-01-24 17:05:44 +01:00
parent 474b53b45e
commit 65d2d49355
7 changed files with 158 additions and 40 deletions

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import type { Id } from '$lib/convex/_generated/dataModel';
interface Photo {
base64: string;
_id: Id<'photoDrafts'>;
mediaType: string;
}

View File

@@ -3,7 +3,7 @@ import { getContext, setContext } from 'svelte';
import type { FunctionReference, FunctionArgs, FunctionReturnType } from 'convex/server';
const POLLING_CONTEXT_KEY = 'convex-polling';
const POLL_INTERVAL = 500;
const POLL_INTERVAL = 1000;
type PollingContext = {
client: ConvexHttpClient;
@@ -47,7 +47,11 @@ 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

@@ -11,10 +11,6 @@ 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()),
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(),
@@ -22,11 +18,23 @@ export const listByChat = query({
})
),
handler: async (ctx, args) => {
return await ctx.db
const messages = 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
}));
}
});

View File

@@ -12,7 +12,6 @@ export const get = query({
photos: v.array(
v.object({
_id: v.id('photoDrafts'),
base64: v.string(),
mediaType: v.string()
})
)
@@ -28,7 +27,6 @@ export const get = query({
return {
photos: drafts.map((d) => ({
_id: d._id,
base64: d.base64,
mediaType: d.mediaType
}))
};

View File

@@ -21,6 +21,20 @@ const photoRequestValidator = v.object({
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'),
@@ -85,9 +99,14 @@ export const markRejected = mutation({
}
});
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(photoRequestValidator, v.null()),
returns: v.union(captureNowLightValidator, v.null()),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
@@ -95,7 +114,10 @@ export const getCaptureNowRequest = query({
.order('desc')
.take(50);
return requests.find((r) => r.status === 'capture_now') ?? null;
const found = requests.find((r) => r.status === 'capture_now');
if (!found) return null;
return { _id: found._id, status: 'capture_now' as const };
}
});
@@ -115,7 +137,7 @@ export const getActiveForCapture = query({
export const getMyActiveRequest = query({
args: { chatId: v.id('chats'), deviceId: v.optional(v.string()) },
returns: v.union(photoRequestValidator, v.null()),
returns: v.union(photoRequestLightValidator, v.null()),
handler: async (ctx, args) => {
const requests = await ctx.db
.query('photoRequests')
@@ -124,13 +146,89 @@ export const getMyActiveRequest = query({
if (!args.deviceId) return null;
return (
requests.find(
(r) =>
r.requesterId === args.deviceId &&
(r.status === 'countdown' || r.status === 'capture_now' || r.status === 'captured')
) ?? 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;
}
});

View File

@@ -4,7 +4,11 @@
import { getContext, onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { useQuery, useConvexClient } from 'convex-svelte';
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
import {
usePollingQuery,
usePollingMutation,
usePollingClient
} 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';
@@ -30,7 +34,6 @@
let activeRequestId: Id<'photoRequests'> | null = $state(null);
let previewPhoto: {
thumbnail: string;
fullBase64: string;
mediaType: string;
requestId: Id<'photoRequests'>;
} | null = $state(null);
@@ -206,15 +209,22 @@
$effect(() => {
const req = myActiveRequest.data;
if (req?.status === 'captured' && req.photoBase64 && req.photoMediaType) {
if (req?.status === 'captured' && req.photoMediaType) {
if (shownPreviewIds.has(req._id)) return;
shownPreviewIds.add(req._id);
previewPhoto = {
thumbnail: req.thumbnailBase64 || req.photoBase64,
fullBase64: req.photoBase64,
mediaType: req.photoMediaType,
requestId: 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
};
}
});
}
}
});
@@ -232,6 +242,7 @@
});
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;
@@ -246,8 +257,10 @@
? usePollingMutation(api.photoRequests.markCaptureNow)
: null;
const submitPhotoPoll = usePolling ? usePollingMutation(api.photoRequests.submitPhoto) : null;
const markAcceptedPoll = usePolling ? usePollingMutation(api.photoRequests.markAccepted) : null;
const markRejectedPoll = usePolling ? usePollingMutation(api.photoRequests.markRejected) : null;
const acceptPhotoToDraftPoll = usePolling
? usePollingMutation(api.photoRequests.acceptPhotoToDraft)
: null;
$effect(() => {
if (!chatId || !deviceId) return;
@@ -467,21 +480,16 @@
function handlePreviewAccept() {
if (!previewPhoto || !chatId) return;
const photo = { base64: previewPhoto.fullBase64, mediaType: previewPhoto.mediaType };
const reqId = previewPhoto.requestId;
previewPhoto = null;
if (usePolling && addPhotoPoll && markAcceptedPoll) {
addPhotoPoll({ chatId, deviceId, photo });
markAcceptedPoll({ requestId: reqId });
if (usePolling && acceptPhotoToDraftPoll) {
acceptPhotoToDraftPoll({ requestId: reqId, chatId, deviceId });
} else if (clientWs) {
clientWs.mutation(api.photoDrafts.addPhoto, {
clientWs.mutation(api.photoRequests.acceptPhotoToDraft, {
requestId: reqId,
chatId,
deviceId,
photo
});
clientWs.mutation(api.photoRequests.markAccepted, {
requestId: reqId
deviceId
});
}
}