fix(frontend): ws replacement

This commit is contained in:
h
2026-01-21 02:03:36 +01:00
parent 11811819f1
commit 69ddb3173f
10 changed files with 290 additions and 60 deletions

View File

@@ -8,9 +8,13 @@ ENV PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
WORKDIR /app WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . . COPY . .
RUN bun i RUN bun --bun svelte-kit sync
RUN bun run build RUN bun --bun run build
ENTRYPOINT ["bun"] ENTRYPOINT ["bun"]

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
*/ */
import type * as chats from "../chats.js"; import type * as chats from "../chats.js";
import type * as http from "../http.js";
import type * as messages from "../messages.js"; import type * as messages from "../messages.js";
import type * as pendingGenerations from "../pendingGenerations.js"; import type * as pendingGenerations from "../pendingGenerations.js";
import type * as users from "../users.js"; import type * as users from "../users.js";
@@ -21,6 +22,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
chats: typeof chats; chats: typeof chats;
http: typeof http;
messages: typeof messages; messages: typeof messages;
pendingGenerations: typeof pendingGenerations; pendingGenerations: typeof pendingGenerations;
users: typeof users; 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 favicon from '$lib/assets/favicon.svg';
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { setupConvex } from 'convex-svelte'; import { setupConvex } from 'convex-svelte';
import { hasWebSocketSupport, setupPollingConvex } from '$lib/convex-polling.svelte';
import { setContext } from 'svelte';
let { children } = $props(); let { children } = $props();
setupConvex(PUBLIC_CONVEX_URL); const usePolling = !hasWebSocketSupport();
setContext('convex-use-polling', usePolling);
if (usePolling) {
setupPollingConvex(PUBLIC_CONVEX_URL);
} else {
setupConvex(PUBLIC_CONVEX_URL);
}
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>

View File

@@ -1,18 +1,35 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { getContext } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { usePollingQuery, usePollingMutation } from '$lib/convex-polling.svelte';
import { api } from '$lib/convex/_generated/api'; import { api } from '$lib/convex/_generated/api';
import ChatMessage from '$lib/components/ChatMessage.svelte'; import ChatMessage from '$lib/components/ChatMessage.svelte';
import ChatInput from '$lib/components/ChatInput.svelte'; import ChatInput from '$lib/components/ChatInput.svelte';
import FollowUpButtons from '$lib/components/FollowUpButtons.svelte'; import FollowUpButtons from '$lib/components/FollowUpButtons.svelte';
const usePolling = getContext<boolean>('convex-use-polling') ?? false;
let mnemonic = $derived(page.params.mnemonic); let mnemonic = $derived(page.params.mnemonic);
const client = useConvexClient();
const chatData = useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip')); const chatDataWs = usePolling
const messagesQuery = useQuery(api.messages.listByChat, () => ? null
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip' : useQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'));
); const chatDataPoll = usePolling
? usePollingQuery(api.chats.getWithUser, () => (mnemonic ? { mnemonic } : 'skip'))
: null;
const chatData = $derived(usePolling ? chatDataPoll! : chatDataWs!);
const messagesQueryWs = usePolling
? null
: useQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
);
const messagesQueryPoll = usePolling
? usePollingQuery(api.messages.listByChat, () =>
chatData.data?.chat?._id ? { chatId: chatData.data.chat._id } : 'skip'
)
: null;
const messagesQuery = $derived(usePolling ? messagesQueryPoll! : messagesQueryWs!);
let messages = $derived(messagesQuery.data ?? []); let messages = $derived(messagesQuery.data ?? []);
let lastMessage = $derived(messages[messages.length - 1]); let lastMessage = $derived(messages[messages.length - 1]);
@@ -22,53 +39,83 @@
: [] : []
); );
let messagesContainer = $state<HTMLDivElement | null>(null);
$effect(() => { $effect(() => {
if (messages.length) { if (messages.length && messagesContainer) {
window.scrollTo(0, document.body.scrollHeight); messagesContainer.scrollTop = messagesContainer.scrollHeight;
} }
}); });
const clientWs = usePolling ? null : useConvexClient();
const createMessagePoll = usePolling ? usePollingMutation(api.messages.create) : null;
async function sendMessage(content: string) { async function sendMessage(content: string) {
const chat = chatData.data?.chat; const chat = chatData.data?.chat;
if (!chat) return; if (!chat) return;
await client.mutation(api.messages.create, { if (usePolling && createMessagePoll) {
chatId: chat._id, await createMessagePoll({
role: 'user', chatId: chat._id,
content, role: 'user',
source: 'web' content,
}); source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content,
source: 'web'
});
}
} }
async function summarize() { async function summarize() {
const chat = chatData.data?.chat; const chat = chatData.data?.chat;
if (!chat) return; if (!chat) return;
await client.mutation(api.messages.create, { if (usePolling && createMessagePoll) {
chatId: chat._id, await createMessagePoll({
role: 'user', chatId: chat._id,
content: '/summarize', role: 'user',
source: 'web' content: '/summarize',
}); source: 'web'
});
} else if (clientWs) {
await clientWs.mutation(api.messages.create, {
chatId: chat._id,
role: 'user',
content: '/summarize',
source: 'web'
});
}
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Chat</title> <title>Chat</title>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</svelte:head> </svelte:head>
<div class="min-h-dvh bg-black text-white"> <div class="fixed inset-0 flex flex-col bg-black text-white">
{#if chatData.isLoading} {#if chatData.isLoading}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Loading...</div> <div class="flex flex-1 items-center justify-center text-xs text-neutral-500">Loading...</div>
{:else if chatData.error} {:else if chatData.error}
<div class="flex min-h-dvh items-center justify-center text-red-500"> <div class="flex flex-1 flex-col items-center justify-center gap-1 p-2 text-red-500">
Error: {chatData.error.toString()} <div class="text-xs">Error</div>
<div class="max-w-full text-center text-[8px] break-all">{chatData.error.message}</div>
</div> </div>
{:else if !chatData.data} {:else if !chatData.data}
<div class="flex min-h-dvh items-center justify-center text-neutral-500">Chat not found</div> <div class="flex flex-1 items-center justify-center text-xs text-neutral-500">Not found</div>
{:else} {:else}
<div class="space-y-1.5 p-2"> <div
bind:this={messagesContainer}
class="flex-1 space-y-1 overflow-y-auto overscroll-contain p-1.5"
>
{#each messages as message (message._id)} {#each messages as message (message._id)}
<ChatMessage <ChatMessage
role={message.role} role={message.role}
@@ -78,23 +125,24 @@
{/each} {/each}
</div> </div>
{#if followUpOptions.length > 0} <div class="shrink-0 border-t border-neutral-800">
<div class="border-t border-neutral-800 px-2 py-1.5"> {#if followUpOptions.length > 0}
<FollowUpButtons options={followUpOptions} onselect={sendMessage} /> <div class="px-1.5 py-1">
<FollowUpButtons options={followUpOptions} onselect={sendMessage} />
</div>
{/if}
<div class="flex gap-1 px-1.5 pb-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> </div>
{/if}
<div class="border-t border-neutral-800 px-2 pt-1.5">
<button
onclick={summarize}
class="rounded bg-neutral-800 px-2 py-1 text-[10px] text-neutral-400"
>
/summarize
</button>
</div>
<div class="p-2 pt-1">
<ChatInput onsubmit={sendMessage} />
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -1,5 +1,22 @@
@import 'tailwindcss'; @import 'tailwindcss';
html,
body {
background: black;
overflow: hidden;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
}
* {
-webkit-tap-highlight-color: transparent;
}
.prose-mini h1, .prose-mini h1,
.prose-mini h2, .prose-mini h2,
.prose-mini h3, .prose-mini h3,

View File

@@ -1,18 +1,18 @@
import adapter from 'svelte-adapter-bun'; 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} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations // Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // 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. // 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. // See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() adapter: adapter()
} }
}; };
export default config; export default config;