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 theme from "./theme.js";
import logoUrl from "../assets/logo.svg"; import logoUrl from "../assets/logo.svg";
import { useStore } from "../state.js"; import { useStore } from "../state.js";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { trpc } from "../trpc/client.js"; import { TRPCProvider, useTRPC } from "../trpc/client.js";
import { usePageContext } from "vike-react/usePageContext"; import { usePageContext } from "vike-react/usePageContext";
import "./hover.css"; 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({ export default function LayoutDefault({
children, children,
}: { children: React.ReactNode }) { }: {
children: React.ReactNode;
}) {
const pageContext = usePageContext(); const pageContext = usePageContext();
const { urlPathname } = pageContext; const { urlPathname } = pageContext;
const [opened, { toggle }] = useDisclosure(); 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 addConversation = useStore((state) => state.addConversation);
const removeConversation = useStore((state) => state.removeConversation); const removeConversation = useStore((state) => state.removeConversation);
const conversationId = useStore((state) => state.selectedConversationId); 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) { async function handleDeleteConversation(conversationId: string) {
removeConversation(conversationId); removeConversation(conversationId);
await trpc.chat.conversations.deleteOne.mutate({ id: conversationId }); await deleteConversation.mutateAsync({ id: conversationId });
const res = await trpc.chat.conversations.start.mutate(); const res = await startConversation.mutateAsync();
if (!res?.id) return; if (!res?.id) return;
addConversation(res); addConversation(res);
await navigate(`/chat/${res.id}`); await navigate(`/chat/${res.id}`);
} }
return ( return (
<MantineProvider theme={theme}> <NavLink
<AppShell key="chat-new"
header={{ height: 60 }} href="#required-for-focus-management"
navbar={{ label={
width: 300, <Group justify="space-between">
breakpoint: "sm", <span>Chats</span>
collapsed: { mobile: !opened }, <IconPlus
}} size={16}
padding="lg" stroke={1.5}
> className="border-on-hover"
<AppShell.Header> onClick={(e) => {
<Group h="100%" px="md"> e.preventDefault();
<Burger e.stopPropagation();
opened={opened} startConversation.mutateAsync().then((res) => {
onClick={toggle} if (!res?.id) return;
hiddenFrom="sm" addConversation(res);
size="sm" navigate(`/chat/${res.id}`);
/> });
<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 </Group>
key="chat-new" }
href="#required-for-focus-management" leftSection={<IconActivity size={16} stroke={1.5} />}
label={ rightSection={
<Group justify="space-between"> <IconChevronRight
<span>Chats</span> size={12}
<IconPlus stroke={1.5}
size={16} className="mantine-rotate-rtl"
stroke={1.5} />
className="border-on-hover" }
onClick={(e) => { variant="subtle"
e.preventDefault(); active={urlPathname.startsWith("/chat")}
e.stopPropagation(); defaultOpened={true}
trpc.chat.conversations.start.mutate().then((res) => { >
if (!res?.id) return; {conversations?.map((conversation) => (
addConversation(res); <NavLink
navigate(`/chat/${res.id}`); key={conversation.id}
}); href={`/chat/${conversation.id}`}
}} label={conversation.title}
/> className="hover-container"
</Group> leftSection={
} <>
leftSection={<IconActivity size={16} stroke={1.5} />} <IconCircle size={16} stroke={1.5} className="show-by-default" />
rightSection={ <IconCircleFilled
<IconChevronRight size={16}
size={12}
stroke={1.5} stroke={1.5}
className="mantine-rotate-rtl" className="show-on-hover"
/> />
} </>
variant="subtle" }
active={urlPathname.startsWith("/chat")} rightSection={
defaultOpened={true} <>
> <IconTrash
{conversations.map((conversation) => ( size={16}
<NavLink stroke={1.5}
key={conversation.id} onClick={(e) => {
href={`/chat/${conversation.id}`} e.stopPropagation();
label={conversation.title} e.preventDefault();
className="hover-container" handleDeleteConversation(conversation.id);
leftSection={ }}
<> className="show-by-default"
<IconCircle />
size={16} <IconTrashFilled
stroke={1.5} size={16}
className="show-by-default" stroke={1.5}
/> onClick={(e) => {
<IconCircleFilled e.stopPropagation();
size={16} e.preventDefault();
stroke={1.5} handleDeleteConversation(conversation.id);
className="show-on-hover" }}
/> className="show-on-hover border-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}
/> />
))} </>
</NavLink> }
</AppShell.Navbar> variant="subtle"
<AppShell.Main> {children} </AppShell.Main> active={conversation.id === conversationId}
</AppShell> />
</MantineProvider> ))}
</NavLink>
); );
} }

@ -20,8 +20,10 @@
"@openrouter/ai-sdk-provider": "^1.1.2", "@openrouter/ai-sdk-provider": "^1.1.2",
"@sinclair/typebox": "^0.34.37", "@sinclair/typebox": "^0.34.37",
"@tabler/icons-react": "^3.34.1", "@tabler/icons-react": "^3.34.1",
"@trpc/client": "^11.4.2", "@tanstack/react-query": "^5.85.3",
"@trpc/server": "^11.4.2", "@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/core": "^0.4.8",
"@universal-middleware/hono": "^0.4.14", "@universal-middleware/hono": "^0.4.14",
"@vitejs/plugin-react": "^4.6.0", "@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, Textarea,
useMantineTheme, useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { trpc } from "../../../trpc/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
defaultParameters, defaultParameters,
@ -23,13 +22,20 @@ import type { Data } from "./+data";
import type { CommittedMessage, DraftMessage } from "../../../types"; import type { CommittedMessage, DraftMessage } from "../../../types";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { IconTrash, IconEdit, IconCheck, IconX } from "@tabler/icons-react"; import { IconTrash, IconEdit, IconCheck, IconX } from "@tabler/icons-react";
import { useTRPCClient } from "../../../trpc/client";
export default function ChatPage() { export default function ChatPage() {
const pageContext = usePageContext(); const pageContext = usePageContext();
const conversationId = pageContext.routeParams.id; 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 messages = useStore((state) => state.messages);
const message = useStore((state) => state.message); const message = useStore((state) => state.message);
const systemPrompt = useStore((state) => state.systemPrompt); const systemPrompt = useStore((state) => state.systemPrompt);
@ -48,6 +54,7 @@ export default function ChatPage() {
const removeFact = useStore((state) => state.removeFact); const removeFact = useStore((state) => state.removeFact);
const removeFactTrigger = useStore((state) => state.removeFactTrigger); const removeFactTrigger = useStore((state) => state.removeFactTrigger);
const setLoading = useStore((state) => state.setLoading); const setLoading = useStore((state) => state.setLoading);
const trpc = useTRPCClient();
// State for editing facts // State for editing facts
const [editingFactId, setEditingFactId] = useState<string | null>(null); const [editingFactId, setEditingFactId] = useState<string | null>(null);
@ -84,13 +91,6 @@ export default function ChatPage() {
}; };
}, [editingFactId, editingFactTriggerId]); }, [editingFactId, editingFactTriggerId]);
const {
conversation,
messages: initialMessages,
facts: initialFacts,
factTriggers: initialFactTriggers,
} = useData<Data>();
useEffect(() => { useEffect(() => {
setConversationId(conversationId); setConversationId(conversationId);
}, [conversationId, setConversationId]); }, [conversationId, setConversationId]);
@ -162,7 +162,7 @@ export default function ChatPage() {
<span>Conversation #{conversationId} - </span> <span>Conversation #{conversationId} - </span>
<input <input
type="text" type="text"
value={conversationTitle || ""} defaultValue={conversationTitle || ""}
onChange={(e) => { onChange={(e) => {
setConversationTitle(e.target.value); setConversationTitle(e.target.value);
}} }}

@ -3,7 +3,7 @@ import {
publicProcedure, publicProcedure,
createCallerFactory, createCallerFactory,
} from "../../trpc/server.js"; } from "../../trpc/server.js";
import { generateObject, generateText, jsonSchema } from "ai"; import { generateObject, generateText, jsonSchema, streamText } from "ai";
import type { import type {
OtherParameters, OtherParameters,
CommittedMessage, CommittedMessage,
@ -47,6 +47,23 @@ export const chat = router({
messages, messages,
facts, facts,
factTriggers, 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 sendMessage: publicProcedure
.input( .input(
(x) => (x) =>

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

@ -35,12 +35,18 @@ importers:
'@tabler/icons-react': '@tabler/icons-react':
specifier: ^3.34.1 specifier: ^3.34.1
version: 3.34.1(react@19.1.0) 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': '@trpc/client':
specifier: ^11.4.2 specifier: ^11.4.4
version: 11.4.2(@trpc/server@11.4.2(typescript@5.8.3))(typescript@5.8.3) version: 11.4.4(@trpc/server@11.4.4(typescript@5.8.3))(typescript@5.8.3)
'@trpc/server': '@trpc/server':
specifier: ^11.4.2 specifier: ^11.4.4
version: 11.4.2(typescript@5.8.3) 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': '@universal-middleware/core':
specifier: ^0.4.8 specifier: ^0.4.8
version: 0.4.8(@cloudflare/workers-types@4.20250627.0)(@hattip/core@0.0.49)(hono@4.8.3) 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': '@tabler/icons@3.34.1':
resolution: {integrity: sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==} resolution: {integrity: sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==}
'@trpc/client@11.4.2': '@tanstack/query-core@5.85.3':
resolution: {integrity: sha512-Eep1rorAsATs9bxgaXf+BV34CRs4lAKQmwumUL4CNdNDkJItyfuWUr3xWx0np1w3EzUDVA0YDMK93iKDBBA0KQ==} 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: peerDependencies:
'@trpc/server': 11.4.2
typescript: '>=5.7.2' typescript: '>=5.7.2'
'@trpc/server@11.4.2': '@trpc/tanstack-react-query@11.4.4':
resolution: {integrity: sha512-THyq/V5bSFDHeWEAk6LqHF0IVTGk6voGwWsFEipzRRKOWWMIZINCsKZ4cISG6kWO2X9jBfMWv/S2o9hnC0zQ0w==} resolution: {integrity: sha512-Rf7jhPfwAHqmQdQlxTiMolNRon85bPEsBNa5JGpjSvhrTmiaFu5UEhEwHDF2myL7IRYxuYHLc4H0bQ+19qjdHQ==}
peerDependencies: 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' typescript: '>=5.7.2'
'@trysound/sax@0.2.0': '@trysound/sax@0.2.0':
@ -3895,15 +3919,31 @@ snapshots:
'@tabler/icons@3.34.1': {} '@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: 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 typescript: 5.8.3
'@trpc/server@11.4.2(typescript@5.8.3)': '@trpc/server@11.4.4(typescript@5.8.3)':
dependencies: dependencies:
typescript: 5.8.3 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': {} '@trysound/sax@0.2.0': {}
'@types/babel__core@7.20.5': '@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"; import type { AppRouter } from "./router.js";
export const trpc = createTRPCProxyClient<AppRouter>({ export const { TRPCProvider, useTRPC, useTRPCClient } =
links: [ createTRPCContext<AppRouter>();
httpBatchLink({
url: "/api/trpc", // export const trpc = createTRPCProxyClient<AppRouter>({
methodOverride: "POST", // links: [
}), // httpBatchLink({
], // url: "/api/trpc",
}); // methodOverride: "POST",
// }),
// ],
// });

@ -6,7 +6,18 @@ import { initTRPC, TRPCError } from "@trpc/server";
* Initialization of tRPC backend * Initialization of tRPC backend
* Should be done only once per 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 * Export reusable router and procedure helpers

Loading…
Cancel
Save