Skip to content

Commit

Permalink
feat: ask questions to chatbot
Browse files Browse the repository at this point in the history
* wip: broke it
* wip: restart
* revert: "wip: restart"
* feat: fetch it right
* chatbot!
* fix: lints, etc
  • Loading branch information
lishaduck committed Mar 15, 2024
1 parent 7f9110e commit 8257f72
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 43 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@
"–": true,
"—": true
}
}
},
"cSpell.words": ["Preact"]
}
5 changes: 5 additions & 0 deletions src/components/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { JSX } from "preact";

export function Loading(): JSX.Element {
return <div class="loader" />;
}
86 changes: 53 additions & 33 deletions src/islands/Chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Transition } from "@headlessui/react";
import { signal, useSignal } from "@preact/signals";
import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts";
import { useSignal } from "@preact/signals";
import type { JSX, RenderableProps } from "preact";
import { useCallback } from "preact/hooks";
import { Suspense } from "preact/compat";
import { useEffect, useId, useMemo } from "preact/hooks";
import { Loading } from "../components/Loading.tsx";
import { useChat } from "../sdk/chat/index.ts";
import { IconMessageChatbot } from "../utils/icons.ts";
import { tw } from "../utils/tailwind.ts";

const thread = signal<string | undefined>(undefined);
const messages = signal<MessageContentText[]>([]);

export function Chatbot(
props: RenderableProps<JSX.HTMLAttributes<HTMLButtonElement>>,
): JSX.Element {
Expand All @@ -34,46 +33,67 @@ export function Chatbot(
leaveFrom={tw`opacity-100`}
leaveTo={tw`opacity-0`}
>
{isOpen.value && (
<div>
<ChatbotBox class="absolute bottom-20 right-0" />
</div>
)}
{isOpen.value && <ChatbotBox class="absolute bottom-20 right-0" />}
</Transition>
</button>
);
}

function ChatbotBox(props: JSX.HTMLAttributes<HTMLDivElement>): JSX.Element {
const nextComment = useCallback(
async (message: string) => {
const response = await fetch(
`/api/chat/?thread=${thread.value ?? ""}&q=${message}`,
);
const json = await response.json();
thread.value ??= json;
messages.value = [...messages.value, json.response];
},
[thread],
);
const messageValue = useSignal("");
const inputId = useId();
const messages = useSignal<string[]>([]);

return (
<div
{...props}
class={`dark:bg-blue-800 bg-blue-400 w-72 h-96 rounded-lg p-5 overflow-y-scroll ${props.class}`}
onClick={async (e) => {
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}`}
onClick={(e) => {
e.stopPropagation();

await nextComment("What is solar power?");
}}
>
{messages.value.map((message) => {
return (
<div class="bg-slate-300 rounded dark:bg-slate-800 p-4 text-sm text-left">
{message.text.value}
</div>
);
})}
{messages.value.map((message) => (
<div key={message}>
<Suspense fallback={<Loading />}>
<div>
<ChatResponse key={message} message={message} />
</div>
</Suspense>
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();

messages.value = [...messages.value, messageValue.value];
}}
>
<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;
}}
/>
</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>
);
}
2 changes: 1 addition & 1 deletion src/routes/api/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const handler: Handlers<MessageContentText | null> = {

const response = await ask(message, thread_id);

return new Response(JSON.stringify({ response }), {
return new Response(JSON.stringify({ response, thread_id }), {
headers: new Headers([["Content-Type", "application/json"]]),
});
},
Expand Down
21 changes: 21 additions & 0 deletions src/sdk/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from "zod";
import { useFetchData } from "../../utils/hooks.ts";
import { messageContentTextSchema } from "../../utils/openai-schemas.ts";

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

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

export function useChat(
message: string,
thread: string | undefined,
): UseChat | undefined {
return useFetchData<UseChat>(
`/api/chat/?${thread ? `thread=${thread}&` : ""}q=${encodeURIComponent(
message,
)}`,
);
}
40 changes: 33 additions & 7 deletions src/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,37 @@
*/
@tailwind variants;

/*
* Add dark mode to the anchor links.
* Temporary, keep until a custom preact renderer.
*/
.prose
:where(a.anchor):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
@apply dark:fill-white;
/* Based on https://css-tricks.com/single-element-loaders-the-dots/ */
.loader,
.loader:before,
.loader:after {
width: 20px; /* update this to control the size */
aspect-ratio: 0.5;
display: grid;
background: radial-gradient(#000 68%, #0000 72%) center/100% 50% no-repeat;
animation: load 1.2s infinite linear calc(var(--_s, 0) * 0.4s);
transform: translate(calc(var(--_s, 0) * 150%));
}
.loader:before,
.loader:after {
content: "";
grid-area: 1/1;
}
.loader:before {
--_s: -1;
}
.loader:after {
--_s: 1;
}

@keyframes load {
20% {
background-position: top;
}
40% {
background-position: bottom;
}
60% {
background-position: center;
}
}
49 changes: 49 additions & 0 deletions src/utils/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useSignal } from "@preact/signals";
import { useCallback, useMemo, useState } from "preact/hooks";

/**
* A suspense-enabled hook.
*/

export function useFetchData<T>(url: string): T | undefined {
const fetchJson = useCallback(async () => {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Error: ${res.statusText}`);
}
return await res.json();
}, [url]);

return use(fetchJson());
}

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

const fetchData = useCallback(async () => {
try {
result.value = await promise;
status.value = "fulfilled";
} catch (e) {
error.value = e;
status.value = "rejected";
}
}, [promise]);

// Preact Signals dislike promises.
const [dataPromise] = useState(fetchData);
const data = useMemo(() => dataPromise, [dataPromise]);

switch (status.value) {
case "pending":
throw data; // Suspend

case "fulfilled":
return result.value; // Result is a fulfilled promise

case "rejected":
throw error.value; // Result is an error
}
}
9 changes: 9 additions & 0 deletions src/utils/openai-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts";
import { z } from "zod";

/**
* This is very basic, and doesn't check anything beyond that it's an object.
*/
export const messageContentTextSchema = z.custom<MessageContentText>(
(val) => z.object({}).safeParse(val).success,
);
2 changes: 1 addition & 1 deletion src/utils/solutions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const solutionPagesSchema = solutionPagesNullableSchema.transform(
*/
export interface MdxFile {
/**
* The default export of the MDX file, which is a preact component.
* The default export of the MDX file, which is a Preact component.
*/
readonly default: ComponentType<{ readonly [x: string]: unknown }>;

Expand Down

0 comments on commit 8257f72

Please sign in to comment.