Skip to content

Commit

Permalink
Merge pull request #24 from PHS-TSA/chatbot
Browse files Browse the repository at this point in the history
Chatbot
  • Loading branch information
lishaduck authored Mar 21, 2024
2 parents 72e3f42 + faf501d commit 6f5504e
Show file tree
Hide file tree
Showing 23 changed files with 544 additions and 27 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"]
}
6 changes: 3 additions & 3 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"tasks": [
{
"type": "deno",
"command": "run",
"command": "task",
"problemMatcher": ["$deno"],
"label": "deno task: start",
"detail": "deno run -A --watch=src/static/,src/routes/ src/dev.ts",
"args": ["-A", "--watch=src/static/,src/routes/", "src/dev.ts"],
"detail": "Run the server",
"args": ["start"],
"group": {
"kind": "build",
"isDefault": true
Expand Down
8 changes: 6 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"test:coverage": "deno coverage --lcov cov/ > cov/cov.lcov",
"test:coverage:genhtml": "genhtml -o cov/html cov/cov.lcov",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch src/dev.ts",
"start": "deno run --env -A --watch src/dev.ts",
"build": "deno task compile:mdx && deno run -A src/dev.ts build",
"preview": "deno run -A src/main.ts",
"preview": "deno run --env -A src/main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update .",
"esm": "deno run -A https://esm.sh/v135",
"esm:add": "deno task esm add",
Expand All @@ -25,6 +25,9 @@
"imports": {
"$std/": "https://deno.land/std@0.216.0/",
"$fresh/": "https://deno.land/x/fresh@1.6.5/",
"$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 @@ -36,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
1 change: 0 additions & 1 deletion src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,6 @@ function RenderCategory(props: Menu): JSX.Element {
* @returns The rendered category header.
*/
// TODO(lishaduck): Add a <Link> component to centralize said styling.
// TODO(lishaduck): Render these all in one section once we have multiple.
function RenderCategoryHeader({ url, title }: BasicMenu): JSX.Element {
return (
<a
Expand Down
9 changes: 9 additions & 0 deletions src/components/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { JSX } from "preact";

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

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

175 changes: 175 additions & 0 deletions src/islands/Chatbot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { render } from "$gfm";
import { Transition } from "@headlessui/react";
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 { useId } from "preact/hooks";
import { Loading } from "../components/Loading.tsx";
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(
props: RenderableProps<JSX.HTMLAttributes<HTMLDivElement>>,
): JSX.Element {
const isOpen = useSignal(false);

return (
<div {...props} class={props.class}>
<button
class="flex size-14 flex-row items-center justify-center rounded-full bg-blue-400 dark:bg-blue-800"
onClick={() => {
isOpen.value = !isOpen.value;
}}
type="button"
aria-label="Meet our Chatbot!"
>
<IconMessageChatbot class="size-8" />
</button>
<Transition
appear={true}
show={isOpen.value}
enter={tw`transition-opacity duration-75`}
enterFrom={tw`opacity-0`}
enterTo={tw`opacity-100`}
leave={tw`transition-opacity duration-150`}
leaveFrom={tw`opacity-100`}
leaveTo={tw`opacity-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 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-[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();
}}
>
<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
class="py-2 place-items-center"
onSubmit={async (e) => {
e.preventDefault();

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>
<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>
);
}
12 changes: 6 additions & 6 deletions src/islands/HeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ function PopoverMenu({
</Popover.Button>

<Transition
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
enter={tw`transition ease-out duration-200`}
enterFrom={tw`opacity-0 translate-y-1`}
enterTo={tw`opacity-100 translate-y-0`}
leave={tw`transition ease-in duration-150`}
leaveFrom={tw`opacity-100 translate-y-0`}
leaveTo={tw`opacity-0 translate-y-1`}
>
<Popover.Panel class="max-w-full">
<div class="absolute left-0 right-auto top-1 z-10 grid max-w-fit origin-top-right grid-flow-row gap-x-4 gap-y-0.5 divide-y divide-gray-200 rounded-md bg-gray-50 px-4 py-1 shadow-lg ring-1 ring-black/5 focus:outline-none sm:left-auto sm:right-0 dark:divide-gray-800 dark:bg-gray-950 dark:ring-white/5">
Expand Down
2 changes: 2 additions & 0 deletions src/routes/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PageProps } from "$fresh/server.ts";
import type { JSX } from "preact";
import { Footer } from "../components/Footer.tsx";
import { Header } from "../components/Header.tsx";
import { Chatbot } from "../islands/Chatbot.tsx";

/**
* Render the layout for all pages.
Expand All @@ -19,6 +20,7 @@ export default function Layout({ Component, url }: PageProps): JSX.Element {
<Header active={url.pathname} />
<Component />
</Partial>
<Chatbot class="fixed right-10 bottom-10" />
<Footer class="mt-auto" />
</div>
);
Expand Down
24 changes: 24 additions & 0 deletions src/routes/api/chat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Handlers } from "$fresh/server.ts";
import type { TextContentBlock } from "openai/resources/beta/threads/messages/messages.ts";
import { ask } from "../../../utils/openai.ts";

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

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

return new Response(JSON.stringify(responses), {
headers: new Headers([["Content-Type", "application/json"]]),
});
},
};
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
2 changes: 1 addition & 1 deletion src/routes/solutions/[category]/[[slug]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const handler: Handlers<SolutionProps> = {
): Promise<Response> {
try {
const { category, slug } = ctx.params;
if (category === undefined) {
if (category === undefined || category === "") {
return await ctx.renderNotFound();
}

Expand Down
6 changes: 5 additions & 1 deletion src/routes/solutions/[category]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export const handler: Handlers<CategoryProps> = {
): Promise<Response> {
try {
const { category } = ctx.params;
if (category === undefined || !isKey(categoryMetadata, category)) {
if (
category === undefined ||
category === "" ||
!isKey(categoryMetadata, category)
) {
return await ctx.renderNotFound();
}

Expand Down
Loading

0 comments on commit 6f5504e

Please sign in to comment.