Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
## 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
- [x] Syntax highlighting of LLM responses via Shiki
- [ ] 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

Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
203 changes: 156 additions & 47 deletions apps/webapp/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -371,72 +386,162 @@ 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);
}
};

return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<div className="flex flex-col h-full">
<div className="relative flex flex-col h-full">
<ThreadHeader threadId={threadId} />
<main
ref={scrollRef}
className={cn(
"flex-1 overflow-y-auto p-4",
hasMessages ? "space-y-4" : "flex flex-col items-center justify-center",
"relative flex-1 overflow-y-auto p-4",
hasMessages ? undefined : "flex flex-col items-center justify-center",
)}
>
{hasMessages &&
messageList.map((m) => (
<div key={m.key} className={cn("flex w-full", m.role === "user" && "justify-end")}>
{m.role === "user" ? (
<div className="bg-secondary text-secondary-foreground text-lg font-normal leading-[140%] tracking-[0.18px] sm:text-base sm:leading-[130%] sm:tracking-[0.16px] rounded-xl px-2 py-1 shadow max-w-[70%] min-w-[10rem] w-fit">
{m.parts.map((part: UIMessage["parts"][number], index: number) => (
<div key={index}>{renderPart(part)}</div>
))}
</div>
) : (
<div className="w-full">{renderMessageParts(m.parts)}</div>
)}
</div>
))}
{!threadId && (
<>
<HyperwaveLogoVertical className="block sm:hidden h-18 sm:h-20 w-auto shrink-0 text-primary" />
<HyperwaveLogoHorizontal className="hidden sm:block h-12 sm:h-16 md:h-18 lg:h-auto w-auto shrink-0 text-primary" />
</>
)}
<div
ref={contentRef}
className={cn(
hasMessages ? "space-y-4" : "flex flex-col items-center justify-center",
)}
>
{hasMessages &&
messageList.map((m) => (
<div
key={m.key}
className={cn("flex w-full", m.role === "user" && "justify-end")}
>
{m.role === "user" ? (
<div className="bg-secondary text-secondary-foreground text-lg font-normal leading-[140%] tracking-[0.18px] sm:text-base sm:leading-[130%] sm:tracking-[0.16px] rounded-xl px-2 py-1 shadow max-w-[70%] min-w-[10rem] w-fit">
{m.parts.map((part: UIMessage["parts"][number], index: number) => (
<div key={index}>{renderPart(part)}</div>
))}
</div>
) : (
<div className="w-full">{renderMessageParts(m.parts)}</div>
)}
</div>
))}
{!threadId && (
<>
<HyperwaveLogoVertical className="block sm:hidden h-18 sm:h-20 w-auto shrink-0 text-primary" />
<HyperwaveLogoHorizontal className="hidden sm:block h-12 sm:h-16 md:h-18 lg:h-auto w-auto shrink-0 text-primary" />
</>
)}
</div>
</main>
{!isAtBottom && (
<button
type="button"
onClick={() => scrollToBottom()}
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-background p-1 shadow"
style={{ bottom: formHeight + 16 }}
>
<ArrowDownCircle className="h-6 w-6" />
<span className="sr-only">Scroll to bottom</span>
</button>
)}
<form ref={formRef} onSubmit={handleSubmit} className="px-4 pb-4 sm:px-6 sm:pb-6">
<div className="bg-background border rounded-xl p-3 shadow-sm flex flex-col gap-3">
<Textarea
Expand All @@ -451,8 +556,12 @@ export function ChatView({
}}
minRows={3}
maxRows={6}
disabled={isCreatingThread || isStreaming}
placeholder="Type a message..."
className="border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 focus-visible:border-0"
className={cn(
"border-0 bg-transparent p-0 shadow-none focus-visible:ring-0 focus-visible:border-0",
isCreatingThread && "opacity-50",
)}
/>
<div className="flex items-end justify-between">
<Popover
Expand Down Expand Up @@ -529,14 +638,14 @@ export function ChatView({
type="submit"
size="icon"
className="rounded-full"
disabled={!modelsLoaded || !prompt.trim() || isStreaming}
disabled={!modelsLoaded || !prompt.trim() || isStreaming || isCreatingThread}
>
{isStreaming ? (
{isCreatingThread ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
<span className="sr-only">{isStreaming ? "Sending..." : "Send"}</span>
<span className="sr-only">Send</span>
</Button>
</div>
</div>
Expand Down
20 changes: 16 additions & 4 deletions apps/webapp/src/components/nav-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
Expand All @@ -17,7 +15,13 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { useAuthActions } from "@convex-dev/auth/react";
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from "lucide-react";
import { ChevronsUpDown, LogOut, Settings } from "lucide-react";
import { useState } from "react";
import { OpenRouterKeyDialog } from "@/components/openrouter-key-dialog";

/**
* Menu displayed in the sidebar showing the current user and a dropdown of actions. Provides access to a settings dialog where users can manage their OpenRouter API key.
*/

export function NavUser({
user,
Expand All @@ -30,8 +34,10 @@ export function NavUser({
}) {
const { signOut } = useAuthActions();
const { isMobile } = useSidebar();
const [settingsOpen, setSettingsOpen] = useState(false);

return (
<>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
Expand All @@ -57,7 +63,6 @@ export function NavUser({
align="end"
sideOffset={4}
>
{/* TODO: Add more user settings */}
{/* <DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
Expand Down Expand Up @@ -93,6 +98,11 @@ export function NavUser({
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator /> */}
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
<Settings />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut()} variant="destructive">
<LogOut />
Log out
Expand All @@ -101,5 +111,7 @@ export function NavUser({
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<OpenRouterKeyDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}
Loading