Skip to content

Commit

Permalink
feat: chattin'!
Browse files Browse the repository at this point in the history
  • Loading branch information
lishaduck committed Mar 21, 2024
1 parent 2319bc9 commit 704c43e
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 90 deletions.
6 changes: 4 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
"imports": {
"$std/": "https://deno.land/std@0.216.0/",
"$fresh/": "https://deno.land/x/fresh@1.6.5/",
"openai": "https://deno.land/x/openai@v4.20.1/mod.ts",
"openai/": "https://deno.land/x/openai@v4.20.1/",
"$gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",
"openai": "https://deno.land/x/openai@v4.29.2/mod.ts",
"openai/": "https://deno.land/x/openai@v4.29.2/",
"$tabler_icons/": "https://deno.land/x/tabler_icons_tsx@0.0.6/tsx/",
"zod": "https://deno.land/x/zod@v3.22.4/mod.ts",
"@headlessui/react": "https://esm.sh/v135/*@headlessui/react@1.7.17",
Expand All @@ -38,6 +39,7 @@
"@mdx-js/mdx": "npm:@mdx-js/mdx@3.0.1",
"@tailwindcss/forms": "npm:@tailwindcss/forms@0.5.7",
"@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.10",
"idb-keyval": "npm:idb-keyval@6.2.1",
"rehype-mathjax": "npm:rehype-mathjax@6.0.0",
"remark-frontmatter": "npm:remark-frontmatter@5.0.0",
"remark-lint-checkbox-content-indent": "npm:remark-lint-checkbox-content-indent@4.1.1",
Expand Down
6 changes: 5 additions & 1 deletion src/components/Loading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { JSX } from "preact";

export function Loading(): JSX.Element {
return <div class="loader" />;
return (
<div class="grid place-items-center w-24">
<div class="loader" />
</div>
);
}
2 changes: 2 additions & 0 deletions src/fresh.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

156 changes: 115 additions & 41 deletions src/islands/Chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { render } from "$gfm";
import { Transition } from "@headlessui/react";
import { useSignal } from "@preact/signals";
import { useSignal, useSignalEffect } from "@preact/signals";
import { set } from "idb-keyval";
import type { TextContentBlock } from "openai/resources/beta/threads/messages/messages.ts";
import type { JSX, RenderableProps } from "preact";
import { Suspense } from "preact/compat";
import { useEffect, useId, useMemo } from "preact/hooks";
import { useId } from "preact/hooks";
import { Loading } from "../components/Loading.tsx";
import { useChat } from "../sdk/chat/index.ts";
import { chat } from "../sdk/chat/index.ts";
import { IconMessageChatbot } from "../utils/icons.ts";
import { IconSend } from "../utils/icons.ts";
import { useIndexedDB } from "../utils/indexeddb.ts";
import { tw } from "../utils/tailwind.ts";

export function Chatbot(
Expand Down Expand Up @@ -35,67 +40,136 @@ export function Chatbot(
leaveFrom={tw`opacity-100`}
leaveTo={tw`opacity-0`}
>
{isOpen.value && <ChatbotBox class="absolute bottom-20 right-0" />}
{isOpen.value && (
// biome-ignore lint/complexity/noUselessFragments: It's necessary.
<Suspense fallback={<></>}>
<ChatbotBox class="absolute bottom-20 right-0" />
</Suspense>
)}
</Transition>
</div>
);
}

const replyStyles = tw`bg-slate-300 rounded-lg dark:bg-slate-800 p-4 prose prose-sm dark:prose-invert max-w-80 sm:max-w-40`;

function getReplySide(role: "assistant" | "user"): string {
switch (role) {
case "assistant":
return tw`text-start mr-auto`;

case "user":
return tw`text-end ml-auto`;
}
}

function ChatbotBox(props: JSX.HTMLAttributes<HTMLDivElement>): JSX.Element {
const messageValue = useSignal("");
const inputId = useId();
const messages = useSignal<string[]>([]);
const isAsking = useSignal(false);
const thread = useIndexedDB<string | undefined>(
"thread",
[],
async () => (await (await fetch("/api/chat/thread/")).json()).thread_id,
);

const messages_ = useIndexedDB<
{ role: "assistant" | "user"; message: string }[]
>(
"messages",
[],
// deno-lint-ignore require-await
async () => [],
);
const messages = useSignal(messages_ ?? []);
useSignalEffect(() => {
set("messages", messages.value);
});

return (
<div
{...props}
class={`dark:bg-blue-800 bg-blue-400 w-72 h-96 rounded-lg p-5 overflow-y-scroll grid place-items-center ${props.class}`}
class={`dark:bg-blue-800 bg-blue-400 w-[90vw] sm:w-72 h-96 rounded-lg p-5 grid grid-flow-row auto-rows-min grid-rows-message-box ${props.class}`}
onClick={(e) => {
e.stopPropagation();
}}
>
{messages.value.map((message) => (
<div key={message}>
<Suspense fallback={<Loading />}>
<div>
<ChatResponse key={message} message={message} />
</div>
</Suspense>
</div>
))}
<div class="flex flex-col-reverse gap-4 overflow-y-auto">
{isAsking.value && (
<div class={replyStyles}>
<Loading />
</div>
)}
{messages.value.map((msg) => (
<div
key={msg.message}
class={`${getReplySide(msg.role)} ${replyStyles}`}
// biome-ignore lint/security/noDangerouslySetInnerHtml: It's back!
dangerouslySetInnerHTML={{ __html: render(msg.message, {}) }}
/>
))}
</div>

<form
onSubmit={(e) => {
class="py-2 place-items-center"
onSubmit={async (e) => {
e.preventDefault();

messages.value = [...messages.value, messageValue.value];
if (thread === undefined) {
throw new Error("Why didn't we suspend before now?");
}

const message = messageValue.value;
if (message === "") {
// If there's no message, don't do anything.
return;
}
messageValue.value = "";
isAsking.value = true;
messages.value = [{ role: "user", message }, ...messages.value];

const reply = await chat(thread, message);

if (reply === undefined) {
// Don't crash when offline.
return;
}

messages.value = reply.map((val) => {
return {
role: val.role,
message: val.content
.filter(
(val2): val2 is TextContentBlock => val2.type === "text",
)
.map((val2) => val2.text.value)
.join(" "),
};
});

isAsking.value = false;
}}
>
<label for={inputId}>Ask A Question, Any Question!</label>
<input
id={inputId}
value={messageValue.value}
autoComplete="off"
onInput={(e) => {
messageValue.value = (e.target as HTMLInputElement).value;
}}
/>
<div class="relative">
<input
id={inputId}
value={messageValue.value}
autoComplete="off"
class="pr-10 w-full rounded-sm dark:text-black whitespace-normal"
onInput={(e) => {
messageValue.value = (e.target as HTMLInputElement).value;
}}
/>
<button
class="absolute right-2 top-0 p-2"
type="submit"
aria-label="Send"
>
<IconSend class="dark:text-black" />
</button>
</div>
</form>
</div>
);
}

function ChatResponse({ message }: { message: string }): JSX.Element {
const thread = useSignal<string | undefined>(undefined);
const json = useChat(message, thread.value);
const data = useMemo(() => json?.response.text.value, [json]);

useEffect(() => {
thread.value ??= json?.thread_id;
}, [json]);

return (
<div class="bg-slate-300 rounded dark:bg-slate-800 p-4 text-sm text-left">
{data}
</div>
);
}
19 changes: 10 additions & 9 deletions src/routes/api/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import type { Handlers } from "$fresh/server.ts";
import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts";
import { newThread } from "../../../utils/openai.ts";
import type { TextContentBlock } from "openai/resources/beta/threads/messages/messages.ts";
import { ask } from "../../../utils/openai.ts";

export const handler: Handlers<MessageContentText | null> = {
export const handler: Handlers<TextContentBlock | null> = {
async GET(req, ctx): Promise<Response> {
const url = new URL(req.url);
const message = url.searchParams.get("q");
if (message === null) {
const thread_id = url.searchParams.get("thread");
if (message === null || thread_id === null) {
return ctx.renderNotFound();
}

const thread = url.searchParams.get("thread");
const thread_id = thread || (await newThread()).id;

const response = await ask(message, thread_id);
const response = ask(message, thread_id);
const responses = [];
for await (const res of response) {
responses.push(res);
}

return new Response(JSON.stringify({ response, thread_id }), {
return new Response(JSON.stringify(responses), {
headers: new Headers([["Content-Type", "application/json"]]),
});
},
Expand Down
13 changes: 13 additions & 0 deletions src/routes/api/chat/thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Handlers } from "$fresh/server.ts";
import type { TextContentBlock } from "openai/resources/beta/threads/messages/messages.ts";
import { newThread } from "../../../utils/openai.ts";

export const handler: Handlers<TextContentBlock | null> = {
async GET(_req, _ctx): Promise<Response> {
const thread = await newThread();

return new Response(JSON.stringify({ thread_id: thread.id }), {
headers: new Headers([["Content-Type", "application/json"]]),
});
},
};
8 changes: 3 additions & 5 deletions src/routes/calculator.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { Head } from "$fresh/runtime.ts";
import type { Handlers, PageProps, RouteConfig } from "$fresh/server.ts";
import type { JSX } from "preact";
import { useId } from "preact/compat";
import { useId } from "preact/hooks";
import { Checkbox } from "../components/Checkbox.tsx";
import { Cover } from "../components/Cover.tsx";
import { Meta } from "../components/Meta.tsx";
import { Selector, type SelectorListObject } from "../islands/Selector.tsx";
import {
type GeoCostBreakdown,
calculatePricingIfHardInstallation,
geothermalLoopType,
} from "../utils/calc/geo.ts";
import {
calculatePricing,
calculatePricingFromType,
calculatePricingIfHardInstallation,
calculatePricingIfRequiresPermit,
calculatePricingMultiplierFromArea,
geothermalLoopType,
} from "../utils/calc/geo.ts";
import {
type State,
Expand Down
26 changes: 11 additions & 15 deletions src/sdk/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import { z } from "zod";
import { useFetchData } from "../../utils/hooks.ts";
import { messageContentTextSchema } from "../../utils/openai-schemas.ts";
import type { z } from "zod";
import { messageSchema } from "../../utils/openai-schemas.ts";

export type UseChat = z.infer<typeof useChatSchema>;
export type ChatThread = z.infer<typeof chatThreadSchema>;

export const useChatSchema = z.object({
response: messageContentTextSchema,
thread_id: z.string(),
});
export const chatThreadSchema = messageSchema.array();

export function useChat(
export async function chat(
thread: string,
message: string,
thread: string | undefined,
): UseChat | undefined {
return useFetchData<UseChat>(
`/api/chat/?${thread ? `thread=${thread}&` : ""}q=${encodeURIComponent(
message,
)}`,
): Promise<ChatThread | undefined> {
const res = await fetch(
`/api/chat/?thread=${thread}&q=${encodeURIComponent(message)}`,
);

return await res.json();
}
1 change: 1 addition & 0 deletions src/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default {
gridTemplateRows: {
"footer-mobile": "repeat(2, auto)",
"footer-desktop": "1fr",
"message-box": "1fr auto",
},
},
},
Expand Down
16 changes: 11 additions & 5 deletions src/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ import { useCallback, useMemo, useState } from "preact/hooks";
* A suspense-enabled hook.
*/

export function useFetchData<T>(url: string): T | undefined {
const fetchJson = useCallback(async () => {
export function useFetchData<T>(url: string | undefined): T | undefined {
const fetchJson = useCallback(async (): Promise<T | undefined> => {
if (url === undefined) {
return undefined;
}

const res = await fetch(url);
if (!res.ok) {
throw new Error(`Error: ${res.statusText}`);
}
return await res.json();
const json = await res.json();

return json as T;
}, [url]);

return use(fetchJson());
return usePromise(fetchJson());
}

export function use<T>(promise: Promise<T>): T | undefined {
export function usePromise<T>(promise: Promise<T>): T | undefined {
const status = useSignal<"pending" | "fulfilled" | "rejected">("pending");
const result = useSignal<T | undefined>(undefined);
const error = useSignal<unknown>(undefined);
Expand Down
Loading

0 comments on commit 704c43e

Please sign in to comment.