diff --git a/README.md b/README.md index baf5432..d27c9d4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Features - [x] Authentication with GitHub -- [ ] Chat with multiple LLMs. Bring your own key via [OpenRouter](https://openrouter.ai) +- [x] Chat with multiple LLMs. Bring your own key via [OpenRouter](https://openrouter.ai) - [ ] Per-user persistent chat history synced to Convex - [ ] Streamed responses - [ ] Resumable streams @@ -13,6 +13,7 @@ - [ ] Ability to share a conversation with another user via a public link - [ ] File and image attachment support - [ ] Web search via Jina +- [x] User-provided API keys encrypted with AES-256 ## Stack @@ -41,6 +42,21 @@ There are two .env files used in this project that you must specify when working - `/apps/webapp/.env` - Environment variables for the webapp - `VITE_CONVEX_URL` - URL of the Convex server, e.g. “https://lively-dog-999.convex.cloud” which you can find in the Convex dashboard - `/packages/backend/.env` - Environment variables for the Convex server. This will be auto-generated for you when you run `pnpm dev:setup` + - `OPENROUTER_API_KEY` - Default OpenRouter API key used when users have not provided their own + - `ENCRYPTION_SECRET` - Base64url encoded 32 byte secret used to encrypt user API keys stored in Convex. Set this in your Convex dashboard so it is available to all deployed functions. Users can store their personal OpenRouter key from the **Settings** dialog in the sidebar. + +You can generate and upload a random encryption secret using the helper script: + +```bash +pnpm set:encryption-secret +``` + +This creates a new base64url-encoded 32‑byte secret and configures it in your Convex project via +`convex env set`. + +Users can optionally provide their own OpenRouter API key from the **Settings** +dialog in the sidebar. Keys must start with `sk-or-` and are encrypted on the +server using the `ENCRYPTION_SECRET` before storage. ## Convex Setup diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 033731c..5a2d29b 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -52,6 +52,7 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tw-animate-css": "^1.3.4", + "use-stick-to-bottom": "^1.1.1", "vaul": "^1.1.2", "zod": "^3.25.63" } diff --git a/apps/webapp/src/components/ChatView.tsx b/apps/webapp/src/components/ChatView.tsx index e872ef4..4aec93a 100644 --- a/apps/webapp/src/components/ChatView.tsx +++ b/apps/webapp/src/components/ChatView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AppSidebar } from "@/components/app-sidebar"; import { HyperwaveLogoHorizontal, HyperwaveLogoVertical } from "@/components/logo"; import { Markdown } from "@/components/markdown"; @@ -15,13 +15,28 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { toUIMessages, useThreadMessages, type UIMessage } from "@convex-dev/agent/react"; +import { + optimisticallySendMessage, + toUIMessages, + useThreadMessages, + type UIMessage, +} from "@convex-dev/agent/react"; import { api } from "@hyperwave/backend/convex/_generated/api"; import type { ModelInfo } from "@hyperwave/backend/convex/models"; import { useNavigate } from "@tanstack/react-router"; import { useQuery } from "convex-helpers/react/cache"; -import { useAction, useMutation } from "convex/react"; -import { ArrowUp, Check, Loader2, MoreHorizontal, Pencil, Trash2, X } from "lucide-react"; +import { useMutation } from "convex/react"; +import { + ArrowDownCircle, + ArrowUp, + Check, + Loader2, + MoreHorizontal, + Pencil, + Trash2, + X, +} from "lucide-react"; +import { useStickToBottom } from "use-stick-to-bottom"; /** * Component that displays the header with thread title, sidebar toggle, and thread actions @@ -371,36 +386,104 @@ export function ChatView({ inputRef.current.focus(); } }, [threadId]); - const messagesQuery = threadId + + // // TODO: Old implementation. To remove. + // const messages = threadId + // ? useThreadMessages( + // api.chat.listThreadMessages, + // { threadId }, + // { + // initialNumItems: 20, + // stream: true, + // }, + // ) + // : undefined; + + const messages = threadId ? useThreadMessages( api.chat.listThreadMessages, { threadId }, - { - initialNumItems: 20, - stream: true, - }, + { initialNumItems: 20, stream: true }, ) : undefined; - const messageList: UIMessage[] = messagesQuery ? toUIMessages(messagesQuery.results ?? []) : []; + + // TODO: Old implementation. To remove. + // const sendMessage = useAction(api.chatActions.sendMessage); + + const sendMessage = useMutation(api.chat.streamMessageAsynchronously).withOptimisticUpdate( + optimisticallySendMessage(api.chat.listThreadMessages), + ); + + const createThread = useMutation(api.chat.createThread); + + const [isCreatingThread, setIsCreatingThread] = useState(false); + + const messageList: UIMessage[] = messages ? toUIMessages(messages.results ?? []) : []; const hasMessages = messageList.length > 0; - const send = useAction(api.chatActions.sendMessage); + const isStreaming = (messages as { streaming?: boolean } | undefined)?.streaming ?? false; + + const { scrollRef, contentRef, scrollToBottom, isAtBottom } = useStickToBottom({ + resize: "smooth", + initial: "smooth", + }); + + /** + * Height of the chat form in pixels. Used to position the + * scroll-to-bottom button above the form with consistent spacing. + */ + const [formHeight, setFormHeight] = useState(0); - const isStreaming = (messagesQuery as { streaming?: boolean } | undefined)?.streaming ?? false; + useLayoutEffect(() => { + const node = formRef.current; + if (!node) return; + const update = () => setFormHeight(node.offsetHeight); + const observer = new ResizeObserver(update); + observer.observe(node); + update(); + return () => observer.disconnect(); + }, []); + /** + * Submit handler for the message form. If a thread already exists it will + * stream the message immediately. Otherwise a new thread is created first + * and the message is optimistically streamed to that thread. + * + * While the thread is being created the input is disabled and a spinner + * replaces the send icon. + */ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const text = prompt.trim(); if (!text || !modelsLoaded || !model) return; - setPrompt(""); - try { - const result = await send({ threadId, prompt: text, model }); - formRef.current?.reset(); - if (!threadId && onNewThread && result.threadId) { - onNewThread(result.threadId); + if (threadId) { + setPrompt(""); + try { + const result = await sendMessage({ threadId, prompt: text, model }); + formRef.current?.reset(); + if (!threadId && onNewThread && result.threadId) { + onNewThread(result.threadId); + } + scrollToBottom(); + } catch (error) { + console.error("Failed to send message:", error); + } + } else { + setIsCreatingThread(true); + try { + const newThreadId = await createThread({}); + // Optimistically send the message but don't await it + void sendMessage({ threadId: newThreadId, prompt: text, model }); + formRef.current?.reset(); + setPrompt(""); + if (onNewThread) { + onNewThread(newThreadId); + } + } catch (error) { + console.error("Failed to create thread:", error); + } finally { + setIsCreatingThread(false); } - } catch (error) { - console.error("Failed to send message:", error); } }; @@ -408,35 +491,57 @@ export function ChatView({ -
+
- {hasMessages && - messageList.map((m) => ( -
- {m.role === "user" ? ( -
- {m.parts.map((part: UIMessage["parts"][number], index: number) => ( -
{renderPart(part)}
- ))} -
- ) : ( -
{renderMessageParts(m.parts)}
- )} -
- ))} - {!threadId && ( - <> - - - - )} +
+ {hasMessages && + messageList.map((m) => ( +
+ {m.role === "user" ? ( +
+ {m.parts.map((part: UIMessage["parts"][number], index: number) => ( +
{renderPart(part)}
+ ))} +
+ ) : ( +
{renderMessageParts(m.parts)}
+ )} +
+ ))} + {!threadId && ( + <> + + + + )} +
+ {!isAtBottom && ( + + )}