fix(frontend): ws replacement

This commit is contained in:
h
2026-01-21 02:03:36 +01:00
parent 277e68f1ed
commit 5a6b4ebacd
7 changed files with 227 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "frontend",

View File

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

View File

@@ -9,6 +9,7 @@
*/
import type * as chats from "../chats.js";
import type * as http from "../http.js";
import type * as messages from "../messages.js";
import type * as pendingGenerations from "../pendingGenerations.js";
import type * as users from "../users.js";
@@ -21,6 +22,7 @@ import type {
declare const fullApi: ApiFromModules<{
chats: typeof chats;
http: typeof http;
messages: typeof messages;
pendingGenerations: typeof pendingGenerations;
users: typeof users;

View File

@@ -0,0 +1,40 @@
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
import { internal } from './_generated/api';
import type { Id } from './_generated/dataModel';
const http = httpRouter();
http.route({
path: '/upload-image',
method: 'POST',
handler: httpAction(async (ctx, req) => {
const chatId = req.headers.get('X-Chat-Id');
const mediaType = req.headers.get('Content-Type') || 'image/jpeg';
const caption = req.headers.get('X-Caption') || '';
if (!chatId) {
return new Response(JSON.stringify({ error: 'Missing X-Chat-Id header' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const blob = await req.blob();
const storageId = await ctx.storage.store(blob);
await ctx.runMutation(internal.messages.createWithImage, {
chatId: chatId as Id<'chats'>,
content: caption,
imageStorageId: storageId,
imageMediaType: mediaType
});
return new Response(JSON.stringify({ storageId }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
})
});
export default http;

View File

@@ -3,10 +3,19 @@
import favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte';
import { hasWebSocketSupport, setupPollingConvex } from '$lib/convex-polling.svelte';
import { setContext } from 'svelte';
let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL);
const usePolling = !hasWebSocketSupport();
setContext('convex-use-polling', usePolling);
if (usePolling) {
setupPollingConvex(PUBLIC_CONVEX_URL);
} else {
setupConvex(PUBLIC_CONVEX_URL);
}
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>

View File

@@ -1,18 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
import { getContext } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
import { api } from '$lib/convex/_generated/api';
import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatInput from '$lib/components/ChatInput.svelte';
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
const usePolling = getContext<boolean>('convex-use-polling') ?? false;
let mnemonic = $derived(page.params.mnemonic);
const client = useConvexClient();
const chatData = useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
const messagesQuery = useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
const chatDataWs = usePolling
? null
: useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
const chatDataPoll = usePolling
? usePollingQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'))
: null;
const chatData = $derived(usePolling ? chatDataPoll! : chatDataWs!);
const messagesQueryWs = usePolling
? null
: useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
const messagesQueryPoll = usePolling
? usePollingQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
)
: null;
const messagesQuery = $derived(usePolling ? messagesQueryPoll! : messagesQueryWs!);
let messages = $derived(messagesQuery.data ?? []);
let lastMessage = $derived(messages[messages.length - 1]);
@@ -28,28 +45,49 @@
}
});
const clientWs = usePolling ? null : useConvexClient();
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
async function sendMessage(content: string) {
const chat = chatData.data?.chat;
if (!chat) return;
await client.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
if (usePolling && createMessagePoll) {
await createMessagePoll({
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
}
}
async function summarize() {
const chat = chatData.data?.chat;
if (!chat) return;
await client.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
if (usePolling && createMessagePoll) {
await createMessagePoll({
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
}
}
</script>

View File

@@ -1,18 +1,18 @@
import adapter from 'svelte-adapter-bun';
import {vitePreprocess} from '@sveltejs/vite-plugin-svelte';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;