begin migration to trpc/react-query integration

master
Avraham Sakal 2 months ago
parent cb749072f2
commit 2d35a4683b

@ -22,165 +22,222 @@ import { useDisclosure } from "@mantine/hooks";
import theme from "./theme.js";
import logoUrl from "../assets/logo.svg";
import { useStore } from "../state.js";
import { useEffect } from "react";
import { trpc } from "../trpc/client.js";
import { useEffect, useState } from "react";
import { TRPCProvider, useTRPC } from "../trpc/client.js";
import { usePageContext } from "vike-react/usePageContext";
import "./hover.css";
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
} from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../trpc/router.js";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export default function LayoutDefault({
children,
}: { children: React.ReactNode }) {
}: {
children: React.ReactNode;
}) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const [opened, { toggle }] = useDisclosure();
const conversations = useStore((state) => state.conversations);
const setConversations = useStore((state) => state.setConversations);
const queryClient = getQueryClient();
const [trpc] = useState(() =>
createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
methodOverride: "POST",
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpc} queryClient={queryClient}>
<MantineProvider theme={theme}>
<AppShell
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened },
}}
padding="lg"
>
<AppShell.Header>
<Group h="100%" px="md">
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<a href="/">
{" "}
<Image h={50} fit="contain" src={logoUrl} />{" "}
</a>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink href="/" label="Welcome" active={urlPathname === "/"} />
<NavLink
href="/todo"
label="Todo"
active={urlPathname === "/todo"}
/>
<NavLink
href="/star-wars"
label="Data Fetching"
active={urlPathname.startsWith("/star-wars")}
/>
<NavLinkChat key="chat-new" />
</AppShell.Navbar>
<AppShell.Main> {children} </AppShell.Main>
</AppShell>
</MantineProvider>
</TRPCProvider>
</QueryClientProvider>
);
}
function NavLinkChat() {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const trpc = useTRPC();
// const
const startConversation = useMutation(
trpc.chat.conversations.start.mutationOptions()
);
const deleteConversation = useMutation(
trpc.chat.conversations.deleteOne.mutationOptions()
);
const { data: conversations } = useQuery(
trpc.chat.conversations.fetchAll.queryOptions()
);
// TODO: should we be using zustand for this, or trpc/react-query's useMutation?
const addConversation = useStore((state) => state.addConversation);
const removeConversation = useStore((state) => state.removeConversation);
const conversationId = useStore((state) => state.selectedConversationId);
useEffect(() => {
trpc.chat.conversations.fetchAll.query().then((res) => {
setConversations(res);
});
}, [setConversations]);
// useEffect(() => {
// if (isConversationListExpanded) {
// trpc.chat.listConversations.query().then((res) => {
// setConversations(res);
// });
// }
// }, [isConversationListExpanded]);
async function handleDeleteConversation(conversationId: string) {
removeConversation(conversationId);
await trpc.chat.conversations.deleteOne.mutate({ id: conversationId });
const res = await trpc.chat.conversations.start.mutate();
await deleteConversation.mutateAsync({ id: conversationId });
const res = await startConversation.mutateAsync();
if (!res?.id) return;
addConversation(res);
await navigate(`/chat/${res.id}`);
}
return (
<MantineProvider theme={theme}>
<AppShell
header={{ height: 60 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened },
}}
padding="lg"
>
<AppShell.Header>
<Group h="100%" px="md">
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="sm"
/>
<a href="/">
{" "}
<Image h={50} fit="contain" src={logoUrl} />{" "}
</a>
</Group>
</AppShell.Header>
<AppShell.Navbar p="md">
<NavLink href="/" label="Welcome" active={urlPathname === "/"} />
<NavLink href="/todo" label="Todo" active={urlPathname === "/todo"} />
<NavLink
href="/star-wars"
label="Data Fetching"
active={urlPathname.startsWith("/star-wars")}
<NavLink
key="chat-new"
href="#required-for-focus-management"
label={
<Group justify="space-between">
<span>Chats</span>
<IconPlus
size={16}
stroke={1.5}
className="border-on-hover"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
startConversation.mutateAsync().then((res) => {
if (!res?.id) return;
addConversation(res);
navigate(`/chat/${res.id}`);
});
}}
/>
<NavLink
key="chat-new"
href="#required-for-focus-management"
label={
<Group justify="space-between">
<span>Chats</span>
<IconPlus
size={16}
stroke={1.5}
className="border-on-hover"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
trpc.chat.conversations.start.mutate().then((res) => {
if (!res?.id) return;
addConversation(res);
navigate(`/chat/${res.id}`);
});
}}
/>
</Group>
}
leftSection={<IconActivity size={16} stroke={1.5} />}
rightSection={
<IconChevronRight
size={12}
</Group>
}
leftSection={<IconActivity size={16} stroke={1.5} />}
rightSection={
<IconChevronRight
size={12}
stroke={1.5}
className="mantine-rotate-rtl"
/>
}
variant="subtle"
active={urlPathname.startsWith("/chat")}
defaultOpened={true}
>
{conversations?.map((conversation) => (
<NavLink
key={conversation.id}
href={`/chat/${conversation.id}`}
label={conversation.title}
className="hover-container"
leftSection={
<>
<IconCircle size={16} stroke={1.5} className="show-by-default" />
<IconCircleFilled
size={16}
stroke={1.5}
className="mantine-rotate-rtl"
className="show-on-hover"
/>
}
variant="subtle"
active={urlPathname.startsWith("/chat")}
defaultOpened={true}
>
{conversations.map((conversation) => (
<NavLink
key={conversation.id}
href={`/chat/${conversation.id}`}
label={conversation.title}
className="hover-container"
leftSection={
<>
<IconCircle
size={16}
stroke={1.5}
className="show-by-default"
/>
<IconCircleFilled
size={16}
stroke={1.5}
className="show-on-hover"
/>
</>
}
rightSection={
<>
<IconTrash
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-by-default"
/>
<IconTrashFilled
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-on-hover border-on-hover"
/>
</>
}
variant="subtle"
active={conversation.id === conversationId}
</>
}
rightSection={
<>
<IconTrash
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-by-default"
/>
<IconTrashFilled
size={16}
stroke={1.5}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteConversation(conversation.id);
}}
className="show-on-hover border-on-hover"
/>
))}
</NavLink>
</AppShell.Navbar>
<AppShell.Main> {children} </AppShell.Main>
</AppShell>
</MantineProvider>
</>
}
variant="subtle"
active={conversation.id === conversationId}
/>
))}
</NavLink>
);
}

@ -20,8 +20,10 @@
"@openrouter/ai-sdk-provider": "^1.1.2",
"@sinclair/typebox": "^0.34.37",
"@tabler/icons-react": "^3.34.1",
"@trpc/client": "^11.4.2",
"@trpc/server": "^11.4.2",
"@tanstack/react-query": "^5.85.3",
"@trpc/client": "^11.4.4",
"@trpc/server": "^11.4.4",
"@trpc/tanstack-react-query": "^11.4.4",
"@universal-middleware/core": "^0.4.8",
"@universal-middleware/hono": "^0.4.14",
"@vitejs/plugin-react": "^4.6.0",

@ -0,0 +1,41 @@
import { Card, Textarea } from "@mantine/core";
import { useState } from "react";
import { useTRPC } from "../../trpc/client";
export default function ChatPage() {
const [inputMessage, setInputMessage] = useState("");
const [loading, setLoading] = useState(false);
const [outputMessage, setOutputMessage] = useState("");
const trpc = useTRPC();
async function handleSendMessage() {
setLoading(true);
const response = await trpc.chat.streamMessage.subscribe(
{
message: inputMessage,
},
{}
);
for await (const chunk of response) {
setOutputMessage(chunk);
}
setLoading(false);
}
return (
<div>
<Card>{outputMessage}</Card>
<Textarea
resize="vertical"
placeholder="Type your message here..."
value={inputMessage}
disabled={loading}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>
</div>
);
}

@ -10,7 +10,6 @@ import {
Textarea,
useMantineTheme,
} from "@mantine/core";
import { trpc } from "../../../trpc/client";
import { useEffect, useState } from "react";
import {
defaultParameters,
@ -23,13 +22,20 @@ import type { Data } from "./+data";
import type { CommittedMessage, DraftMessage } from "../../../types";
import Markdown from "react-markdown";
import { IconTrash, IconEdit, IconCheck, IconX } from "@tabler/icons-react";
import { useTRPCClient } from "../../../trpc/client";
export default function ChatPage() {
const pageContext = usePageContext();
const conversationId = pageContext.routeParams.id;
const conversationTitle = useStore(
(state) => state.conversations.find((c) => c.id === conversationId)?.title
);
const {
conversation,
messages: initialMessages,
facts: initialFacts,
factTriggers: initialFactTriggers,
} = useData<Data>();
const conversationTitle = conversation?.title;
const messages = useStore((state) => state.messages);
const message = useStore((state) => state.message);
const systemPrompt = useStore((state) => state.systemPrompt);
@ -48,6 +54,7 @@ export default function ChatPage() {
const removeFact = useStore((state) => state.removeFact);
const removeFactTrigger = useStore((state) => state.removeFactTrigger);
const setLoading = useStore((state) => state.setLoading);
const trpc = useTRPCClient();
// State for editing facts
const [editingFactId, setEditingFactId] = useState<string | null>(null);
@ -84,13 +91,6 @@ export default function ChatPage() {
};
}, [editingFactId, editingFactTriggerId]);
const {
conversation,
messages: initialMessages,
facts: initialFacts,
factTriggers: initialFactTriggers,
} = useData<Data>();
useEffect(() => {
setConversationId(conversationId);
}, [conversationId, setConversationId]);
@ -162,7 +162,7 @@ export default function ChatPage() {
<span>Conversation #{conversationId} - </span>
<input
type="text"
value={conversationTitle || ""}
defaultValue={conversationTitle || ""}
onChange={(e) => {
setConversationTitle(e.target.value);
}}

@ -3,7 +3,7 @@ import {
publicProcedure,
createCallerFactory,
} from "../../trpc/server.js";
import { generateObject, generateText, jsonSchema } from "ai";
import { generateObject, generateText, jsonSchema, streamText } from "ai";
import type {
OtherParameters,
CommittedMessage,
@ -47,6 +47,23 @@ export const chat = router({
messages,
facts,
factTriggers,
streamMessage: publicProcedure
.input(
(x) =>
x as {
message: string;
}
)
.subscription(async function* ({ input, signal }) {
const result = streamText({
model: openrouter(MODEL_NAME),
messages: [{ role: "user" as const, content: input.message }],
abortSignal: signal,
});
for await (const chunk of result.textStream) {
yield chunk;
}
}),
sendMessage: publicProcedure
.input(
(x) =>

@ -1,5 +1,5 @@
import { trpc } from "../../trpc/client";
import { useState } from "react";
import { useTRPCClient } from "../../trpc/client";
export function TodoList({
initialTodoItems,
@ -8,6 +8,8 @@ export function TodoList({
}) {
const [todoItems, setTodoItems] = useState(initialTodoItems);
const [newTodo, setNewTodo] = useState("");
const trpc = useTRPCClient();
return (
<>
<ul>

@ -35,12 +35,18 @@ importers:
'@tabler/icons-react':
specifier: ^3.34.1
version: 3.34.1(react@19.1.0)
'@tanstack/react-query':
specifier: ^5.85.3
version: 5.85.3(react@19.1.0)
'@trpc/client':
specifier: ^11.4.2
version: 11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3)
specifier: ^11.4.4
version: 11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3)
'@trpc/server':
specifier: ^11.4.2
version: 11.4.2(typescript@5.8.3)
specifier: ^11.4.4
version: 11.4.4(typescript@5.8.3)
'@trpc/tanstack-react-query':
specifier: ^11.4.4
version: 11.4.4(@tanstack/react-query@5.85.3(react@19.1.0))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.4(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
'@universal-middleware/core':
specifier: ^0.4.8
version: 0.4.8(@cloudflare/workers-types@4.20250627.0)(@hattip/core@0.0.49)(hono@4.8.3)
@ -1150,15 +1156,33 @@ packages:
'@tabler/icons@3.34.1':
resolution: {integrity: sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==}
'@trpc/client@11.4.2':
resolution: {integrity: sha512-Eep1rorAsATs9bxgaXf+BV34CRs4lAKQmwumUL4CNdNDkJItyfuWUr3xWx0np1w3EzUDVA0YDMK93iKDBBA0KQ==}
'@tanstack/query-core@5.85.3':
resolution: {integrity: sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==}
'@tanstack/react-query@5.85.3':
resolution: {integrity: sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==}
peerDependencies:
react: ^18 || ^19
'@trpc/client@11.4.4':
resolution: {integrity: sha512-86OZl+Y+Xlt9ITGlhCMImERcsWCOrVzpNuzg3XBlsDSmSs9NGsghKjeCpJQlE36XaG3aze+o9pRukiYYvBqxgQ==}
peerDependencies:
'@trpc/server': 11.4.4
typescript: '>=5.7.2'
'@trpc/server@11.4.4':
resolution: {integrity: sha512-VkJb2xnb4rCynuwlCvgPBh5aM+Dco6fBBIo6lWAdJJRYVwtyE5bxNZBgUvRRz/cFSEAy0vmzLxF7aABDJfK5Rg==}
peerDependencies:
'@trpc/server': 11.4.2
typescript: '>=5.7.2'
'@trpc/server@11.4.2':
resolution: {integrity: sha512-THyq/V5bSFDHeWEAk6LqHF0IVTGk6voGwWsFEipzRRKOWWMIZINCsKZ4cISG6kWO2X9jBfMWv/S2o9hnC0zQ0w==}
'@trpc/tanstack-react-query@11.4.4':
resolution: {integrity: sha512-Rf7jhPfwAHqmQdQlxTiMolNRon85bPEsBNa5JGpjSvhrTmiaFu5UEhEwHDF2myL7IRYxuYHLc4H0bQ+19qjdHQ==}
peerDependencies:
'@tanstack/react-query': ^5.80.3
'@trpc/client': 11.4.4
'@trpc/server': 11.4.4
react: '>=18.2.0'
react-dom: '>=18.2.0'
typescript: '>=5.7.2'
'@trysound/sax@0.2.0':
@ -3895,15 +3919,31 @@ snapshots:
'@tabler/icons@3.34.1': {}
'@trpc/client@11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3)':
'@tanstack/query-core@5.85.3': {}
'@tanstack/react-query@5.85.3(react@19.1.0)':
dependencies:
'@trpc/server': 11.4.2(typescript@5.8.3)
'@tanstack/query-core': 5.85.3
react: 19.1.0
'@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3)':
dependencies:
'@trpc/server': 11.4.4(typescript@5.8.3)
typescript: 5.8.3
'@trpc/server@11.4.2(typescript@5.8.3)':
'@trpc/server@11.4.4(typescript@5.8.3)':
dependencies:
typescript: 5.8.3
'@trpc/tanstack-react-query@11.4.4(@tanstack/react-query@5.85.3(react@19.1.0))(@trpc/client@11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3))(@trpc/server@11.4.4(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)':
dependencies:
'@tanstack/react-query': 5.85.3(react@19.1.0)
'@trpc/client': 11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3)
'@trpc/server': 11.4.4(typescript@5.8.3)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
typescript: 5.8.3
'@trysound/sax@0.2.0': {}
'@types/babel__core@7.20.5':

@ -1,11 +1,15 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
// import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import type { AppRouter } from "./router.js";
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
methodOverride: "POST",
}),
],
});
export const { TRPCProvider, useTRPC, useTRPCClient } =
createTRPCContext<AppRouter>();
// export const trpc = createTRPCProxyClient<AppRouter>({
// links: [
// httpBatchLink({
// url: "/api/trpc",
// methodOverride: "POST",
// }),
// ],
// });

@ -6,7 +6,18 @@ import { initTRPC, TRPCError } from "@trpc/server";
* Initialization of tRPC backend
* Should be done only once per backend!
*/
const t = initTRPC.context<object>().create();
const t = initTRPC.context<object>().create(/*{
sse: {
maxDurationMs: 5 * 60 * 1_000, // 5 minutes
ping: {
enabled: true,
intervalMs: 3_000,
},
client: {
reconnectAfterInactivityMs: 5_000,
},
},
}*/);
/**
* Export reusable router and procedure helpers

Loading…
Cancel
Save