From 3ffa5a57e54daca634df33e5d51e6831c404f4f7 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Wed, 6 Mar 2024 21:26:10 -0600 Subject: [PATCH 1/5] feat: basic chatbot You can't ask questions yet, but the server can! --- .vscode/tasks.json | 6 +- deno.json | 6 +- src/fresh.gen.ts | 4 + src/islands/Chatbot.tsx | 79 ++++++++++++++++++++ src/islands/HeaderMenu.tsx | 12 +-- src/routes/_layout.tsx | 2 + src/routes/api/chat/index.ts | 23 ++++++ src/routes/solutions/[category]/[[slug]].tsx | 2 +- src/routes/solutions/[category]/index.tsx | 6 +- src/utils/icons.ts | 8 ++ src/utils/openai.ts | 67 +++++++++++++++++ 11 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/islands/Chatbot.tsx create mode 100644 src/routes/api/chat/index.ts create mode 100644 src/utils/openai.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0002cb86..38cbe1f8 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -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 diff --git a/deno.json b/deno.json index 79a0da21..27adb5c1 100644 --- a/deno.json +++ b/deno.json @@ -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", @@ -25,6 +25,8 @@ "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/", "$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", diff --git a/src/fresh.gen.ts b/src/fresh.gen.ts index d1891561..b524f58a 100644 --- a/src/fresh.gen.ts +++ b/src/fresh.gen.ts @@ -7,10 +7,12 @@ import * as $_500 from "./routes/_500.tsx"; import * as $_app from "./routes/_app.tsx"; import * as $_layout from "./routes/_layout.tsx"; import * as $about from "./routes/about.tsx"; +import * as $api_chat_index from "./routes/api/chat/index.ts"; import * as $calculator from "./routes/calculator.tsx"; import * as $index from "./routes/index.tsx"; import * as $solutions_category_slug_ from "./routes/solutions/[category]/[[slug]].tsx"; import * as $solutions_category_index from "./routes/solutions/[category]/index.tsx"; +import * as $Chatbot from "./islands/Chatbot.tsx"; import * as $HeaderMenu from "./islands/HeaderMenu.tsx"; import * as $Selector from "./islands/Selector.tsx"; import { type Manifest } from "$fresh/server.ts"; @@ -22,12 +24,14 @@ const manifest = { "./routes/_app.tsx": $_app, "./routes/_layout.tsx": $_layout, "./routes/about.tsx": $about, + "./routes/api/chat/index.ts": $api_chat_index, "./routes/calculator.tsx": $calculator, "./routes/index.tsx": $index, "./routes/solutions/[category]/[[slug]].tsx": $solutions_category_slug_, "./routes/solutions/[category]/index.tsx": $solutions_category_index, }, islands: { + "./islands/Chatbot.tsx": $Chatbot, "./islands/HeaderMenu.tsx": $HeaderMenu, "./islands/Selector.tsx": $Selector, }, diff --git a/src/islands/Chatbot.tsx b/src/islands/Chatbot.tsx new file mode 100644 index 00000000..a6342df6 --- /dev/null +++ b/src/islands/Chatbot.tsx @@ -0,0 +1,79 @@ +import { Transition } from "@headlessui/react"; +import { signal, useSignal } from "@preact/signals"; +import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts"; +import type { JSX, RenderableProps } from "preact"; +import { useCallback } from "preact/hooks"; +import { IconMessageChatbot } from "../utils/icons.ts"; +import { tw } from "../utils/tailwind.ts"; + +const thread = signal(undefined); +const messages = signal([]); + +export function Chatbot( + props: RenderableProps>, +): JSX.Element { + const isOpen = useSignal(false); + + return ( + + ); +} + +function ChatbotBox(props: JSX.HTMLAttributes): 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], + ); + + return ( +
{ + e.stopPropagation(); + + await nextComment("What is solar power?"); + }} + > + {messages.value.map((message) => { + return ( +
+ {message.text.value} +
+ ); + })} +
+ ); +} diff --git a/src/islands/HeaderMenu.tsx b/src/islands/HeaderMenu.tsx index 6db78b73..39ed2d55 100644 --- a/src/islands/HeaderMenu.tsx +++ b/src/islands/HeaderMenu.tsx @@ -89,12 +89,12 @@ function PopoverMenu({
diff --git a/src/routes/_layout.tsx b/src/routes/_layout.tsx index e1ac38da..ce4d72ae 100644 --- a/src/routes/_layout.tsx +++ b/src/routes/_layout.tsx @@ -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. @@ -19,6 +20,7 @@ export default function Layout({ Component, url }: PageProps): JSX.Element {
+
); diff --git a/src/routes/api/chat/index.ts b/src/routes/api/chat/index.ts new file mode 100644 index 00000000..12db3e45 --- /dev/null +++ b/src/routes/api/chat/index.ts @@ -0,0 +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 { ask } from "../../../utils/openai.ts"; + +export const handler: Handlers = { + async GET(req, ctx): Promise { + const url = new URL(req.url); + const message = url.searchParams.get("q"); + if (message === null) { + return ctx.renderNotFound(); + } + + const thread = url.searchParams.get("thread"); + const thread_id = thread || (await newThread()).id; + + const response = await ask(message, thread_id); + + return new Response(JSON.stringify({ response }), { + headers: new Headers([["Content-Type", "application/json"]]), + }); + }, +}; diff --git a/src/routes/solutions/[category]/[[slug]].tsx b/src/routes/solutions/[category]/[[slug]].tsx index 5d107bb6..1809fa0e 100644 --- a/src/routes/solutions/[category]/[[slug]].tsx +++ b/src/routes/solutions/[category]/[[slug]].tsx @@ -36,7 +36,7 @@ export const handler: Handlers = { ): Promise { try { const { category, slug } = ctx.params; - if (category === undefined) { + if (category === undefined || category === "") { return await ctx.renderNotFound(); } diff --git a/src/routes/solutions/[category]/index.tsx b/src/routes/solutions/[category]/index.tsx index 11e539ce..4495441b 100644 --- a/src/routes/solutions/[category]/index.tsx +++ b/src/routes/solutions/[category]/index.tsx @@ -45,7 +45,11 @@ export const handler: Handlers = { ): Promise { try { const { category } = ctx.params; - if (category === undefined || !isKey(categoryMetadata, category)) { + if ( + category === undefined || + category === "" || + !isKey(categoryMetadata, category) + ) { return await ctx.renderNotFound(); } diff --git a/src/utils/icons.ts b/src/utils/icons.ts index 94068d3c..40c32d50 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -11,6 +11,7 @@ import IconFlameComponent from "$tabler_icons/flame.tsx"; import IconInfoCircleComponent from "$tabler_icons/info-circle.tsx"; import IconLemon2Component from "$tabler_icons/lemon-2.tsx"; import IconLinkComponent from "$tabler_icons/link.tsx"; +import IconMessageChatbotComponent from "$tabler_icons/message-chatbot.tsx"; import IconSolarPanel2Component from "$tabler_icons/solar-panel-2.tsx"; import IconSolarPanelComponent from "$tabler_icons/solar-panel.tsx"; @@ -91,3 +92,10 @@ export const IconFlame: Icon = IconFlameComponent; export const IconCheck: Icon = IconCheckComponent; export const IconLink: Icon = IconLinkComponent; + +/** + * A chatbot icon. + * + * It's a smiling robot! + */ +export const IconMessageChatbot: Icon = IconMessageChatbotComponent; diff --git a/src/utils/openai.ts b/src/utils/openai.ts new file mode 100644 index 00000000..d64e2ae1 --- /dev/null +++ b/src/utils/openai.ts @@ -0,0 +1,67 @@ +import { OpenAI } from "openai"; +import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts"; +import type { Thread } from "openai/resources/beta/threads/threads.ts"; + +let client: OpenAI; +let ASSISTANT_ID: string; + +// Try to connect to the real OpenAI API, if it fails, use the mock API. +try { + client = new OpenAI(); + ASSISTANT_ID = getAssistantId(); +} catch { + client = new OpenAI({ + baseURL: "https://mockgpt.wiremockapi.cloud/v1", + apiKey: "sk-3eo4svsr4bah2qc9h70sdbvrf12du8o4", + }); + ASSISTANT_ID = ""; +} + +function getAssistantId(): string { + const id = Deno.env.get("ASSISTANT_ID"); + if (id === undefined) { + throw new Error("ASSISTANT_ID is not set"); + } + + return id; +} + +export async function newThread(): Promise { + return await client.beta.threads.create(); +} + +export async function ask( + q: string, + thread_id: string, + assistant_id: string = ASSISTANT_ID, +): Promise { + await client.beta.threads.messages.create(thread_id, { + role: "user", + content: q, + }); + let run = await client.beta.threads.runs.create(thread_id, { + assistant_id, + }); + + while ( + run.status === "in_progress" || + run.status === "queued" || + run.status === "requires_action" + ) { + // Make sure we don't poll too frequently. + // deno-lint-ignore no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Polling is required, as streaming is not yet supported. + // deno-lint-ignore no-await-in-loop + run = await client.beta.threads.runs.retrieve(thread_id, run.id); + } + + const messages = await client.beta.threads.messages.list(thread_id); + const lastMessage = messages.data[0]?.content[0]; + + if (lastMessage?.type !== "text") { + throw new Error("We don't support images."); + } + return lastMessage; +} From ff66b91c8814849ca42e57413b059a0edaab79c2 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Fri, 8 Mar 2024 20:06:21 -0600 Subject: [PATCH 2/5] feat: ask questions to chatbot * wip: broke it * wip: restart * revert: "wip: restart" * feat: fetch it right * chatbot! * fix: lints, etc --- .vscode/settings.json | 3 +- src/components/Loading.tsx | 5 +++ src/islands/Chatbot.tsx | 86 ++++++++++++++++++++++-------------- src/routes/api/chat/index.ts | 2 +- src/sdk/chat/index.ts | 21 +++++++++ src/static/styles.css | 40 ++++++++++++++--- src/utils/hooks.ts | 49 ++++++++++++++++++++ src/utils/openai-schemas.ts | 9 ++++ 8 files changed, 173 insertions(+), 42 deletions(-) create mode 100644 src/components/Loading.tsx create mode 100644 src/sdk/chat/index.ts create mode 100644 src/utils/hooks.ts create mode 100644 src/utils/openai-schemas.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 28c92d4c..f4b5a106 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,5 +32,6 @@ "–": true, "—": true } - } + }, + "cSpell.words": ["Preact"] } diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx new file mode 100644 index 00000000..4f4bfacb --- /dev/null +++ b/src/components/Loading.tsx @@ -0,0 +1,5 @@ +import type { JSX } from "preact"; + +export function Loading(): JSX.Element { + return
; +} diff --git a/src/islands/Chatbot.tsx b/src/islands/Chatbot.tsx index a6342df6..c026102d 100644 --- a/src/islands/Chatbot.tsx +++ b/src/islands/Chatbot.tsx @@ -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(undefined); -const messages = signal([]); - export function Chatbot( props: RenderableProps>, ): JSX.Element { @@ -34,46 +33,67 @@ export function Chatbot( leaveFrom={tw`opacity-100`} leaveTo={tw`opacity-0`} > - {isOpen.value && ( -
- -
- )} + {isOpen.value && } ); } function ChatbotBox(props: JSX.HTMLAttributes): 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([]); return (
{ + 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 ( -
- {message.text.value} -
- ); - })} + {messages.value.map((message) => ( +
+ }> +
+ +
+
+
+ ))} +
{ + e.preventDefault(); + + messages.value = [...messages.value, messageValue.value]; + }} + > + + { + messageValue.value = (e.target as HTMLInputElement).value; + }} + /> +
+
+ ); +} + +function ChatResponse({ message }: { message: string }): JSX.Element { + const thread = useSignal(undefined); + const json = useChat(message, thread.value); + const data = useMemo(() => json?.response.text.value, [json]); + + useEffect(() => { + thread.value ??= json?.thread_id; + }, [json]); + + return ( +
+ {data}
); } diff --git a/src/routes/api/chat/index.ts b/src/routes/api/chat/index.ts index 12db3e45..0ae6e0ed 100644 --- a/src/routes/api/chat/index.ts +++ b/src/routes/api/chat/index.ts @@ -16,7 +16,7 @@ export const handler: Handlers = { 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"]]), }); }, diff --git a/src/sdk/chat/index.ts b/src/sdk/chat/index.ts new file mode 100644 index 00000000..44ed1773 --- /dev/null +++ b/src/sdk/chat/index.ts @@ -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; + +export const useChatSchema = z.object({ + response: messageContentTextSchema, + thread_id: z.string(), +}); + +export function useChat( + message: string, + thread: string | undefined, +): UseChat | undefined { + return useFetchData( + `/api/chat/?${thread ? `thread=${thread}&` : ""}q=${encodeURIComponent( + message, + )}`, + ); +} diff --git a/src/static/styles.css b/src/static/styles.css index 0c0b2a3c..c8b4ca78 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -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; + } } diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts new file mode 100644 index 00000000..e38c8511 --- /dev/null +++ b/src/utils/hooks.ts @@ -0,0 +1,49 @@ +import { useSignal } from "@preact/signals"; +import { useCallback, useMemo, useState } from "preact/hooks"; + +/** + * A suspense-enabled hook. + */ + +export function useFetchData(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(promise: Promise): T | undefined { + const status = useSignal<"pending" | "fulfilled" | "rejected">("pending"); + const result = useSignal(undefined); + const error = useSignal(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 + } +} diff --git a/src/utils/openai-schemas.ts b/src/utils/openai-schemas.ts new file mode 100644 index 00000000..2f8cee33 --- /dev/null +++ b/src/utils/openai-schemas.ts @@ -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( + (val) => z.object({}).safeParse(val).success, +); From 3884bd6a7b977059f2b2091eedaec2c0a11d5b67 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Sun, 17 Mar 2024 16:24:56 -0500 Subject: [PATCH 3/5] feat: yahhhhhhhhoooooooooooooooo! I guess nested buttons aren't a thing in HTML :) --- src/islands/Chatbot.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/islands/Chatbot.tsx b/src/islands/Chatbot.tsx index c026102d..7530f170 100644 --- a/src/islands/Chatbot.tsx +++ b/src/islands/Chatbot.tsx @@ -9,20 +9,22 @@ import { IconMessageChatbot } from "../utils/icons.ts"; import { tw } from "../utils/tailwind.ts"; export function Chatbot( - props: RenderableProps>, + props: RenderableProps>, ): JSX.Element { const isOpen = useSignal(false); return ( - {isOpen.value && } - +
); } From 72a69e1752d8be600b08445054ad143a00f85e3a Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Wed, 20 Mar 2024 17:33:14 -0500 Subject: [PATCH 4/5] feat: chattin'! --- deno.json | 6 +- src/components/Loading.tsx | 6 +- src/fresh.gen.ts | 2 + src/islands/Chatbot.tsx | 156 +++++++++++++++++++++++++--------- src/routes/api/chat/index.ts | 19 +++-- src/routes/api/chat/thread.ts | 13 +++ src/routes/calculator.tsx | 8 +- src/sdk/chat/index.ts | 26 +++--- src/tailwind.config.ts | 1 + src/utils/hooks.ts | 16 ++-- src/utils/icons.ts | 8 ++ src/utils/indexeddb.ts | 39 +++++++++ src/utils/openai-schemas.ts | 4 +- src/utils/openai.ts | 17 ++-- src/utils/openai/ref.ts | 55 ++++++++++++ 15 files changed, 286 insertions(+), 90 deletions(-) create mode 100644 src/routes/api/chat/thread.ts create mode 100644 src/utils/indexeddb.ts create mode 100644 src/utils/openai/ref.ts diff --git a/deno.json b/deno.json index 27adb5c1..83863af6 100644 --- a/deno.json +++ b/deno.json @@ -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", @@ -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", diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 4f4bfacb..71058ca5 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -1,5 +1,9 @@ import type { JSX } from "preact"; export function Loading(): JSX.Element { - return
; + return ( +
+
+
+ ); } diff --git a/src/fresh.gen.ts b/src/fresh.gen.ts index b524f58a..1f690978 100644 --- a/src/fresh.gen.ts +++ b/src/fresh.gen.ts @@ -8,6 +8,7 @@ import * as $_app from "./routes/_app.tsx"; import * as $_layout from "./routes/_layout.tsx"; import * as $about from "./routes/about.tsx"; import * as $api_chat_index from "./routes/api/chat/index.ts"; +import * as $api_chat_thread from "./routes/api/chat/thread.ts"; import * as $calculator from "./routes/calculator.tsx"; import * as $index from "./routes/index.tsx"; import * as $solutions_category_slug_ from "./routes/solutions/[category]/[[slug]].tsx"; @@ -25,6 +26,7 @@ const manifest = { "./routes/_layout.tsx": $_layout, "./routes/about.tsx": $about, "./routes/api/chat/index.ts": $api_chat_index, + "./routes/api/chat/thread.ts": $api_chat_thread, "./routes/calculator.tsx": $calculator, "./routes/index.tsx": $index, "./routes/solutions/[category]/[[slug]].tsx": $solutions_category_slug_, diff --git a/src/islands/Chatbot.tsx b/src/islands/Chatbot.tsx index 7530f170..fe5fbb6d 100644 --- a/src/islands/Chatbot.tsx +++ b/src/islands/Chatbot.tsx @@ -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( @@ -35,67 +40,136 @@ export function Chatbot( leaveFrom={tw`opacity-100`} leaveTo={tw`opacity-0`} > - {isOpen.value && } + {isOpen.value && ( + // biome-ignore lint/complexity/noUselessFragments: It's necessary. + }> + + + )}
); } +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): JSX.Element { const messageValue = useSignal(""); const inputId = useId(); - const messages = useSignal([]); + const isAsking = useSignal(false); + const thread = useIndexedDB( + "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 (
{ e.stopPropagation(); }} > - {messages.value.map((message) => ( -
- }> -
- -
-
-
- ))} +
+ {isAsking.value && ( +
+ +
+ )} + {messages.value.map((msg) => ( +
+ ))} +
+
{ + 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; }} > - { - messageValue.value = (e.target as HTMLInputElement).value; - }} - /> +
+ { + messageValue.value = (e.target as HTMLInputElement).value; + }} + /> + +
); } - -function ChatResponse({ message }: { message: string }): JSX.Element { - const thread = useSignal(undefined); - const json = useChat(message, thread.value); - const data = useMemo(() => json?.response.text.value, [json]); - - useEffect(() => { - thread.value ??= json?.thread_id; - }, [json]); - - return ( -
- {data} -
- ); -} diff --git a/src/routes/api/chat/index.ts b/src/routes/api/chat/index.ts index 0ae6e0ed..2eca5658 100644 --- a/src/routes/api/chat/index.ts +++ b/src/routes/api/chat/index.ts @@ -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 = { +export const handler: Handlers = { async GET(req, ctx): Promise { 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"]]), }); }, diff --git a/src/routes/api/chat/thread.ts b/src/routes/api/chat/thread.ts new file mode 100644 index 00000000..f80e1f59 --- /dev/null +++ b/src/routes/api/chat/thread.ts @@ -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 = { + async GET(_req, _ctx): Promise { + const thread = await newThread(); + + return new Response(JSON.stringify({ thread_id: thread.id }), { + headers: new Headers([["Content-Type", "application/json"]]), + }); + }, +}; diff --git a/src/routes/calculator.tsx b/src/routes/calculator.tsx index a11987cb..d4415c05 100644 --- a/src/routes/calculator.tsx +++ b/src/routes/calculator.tsx @@ -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, diff --git a/src/sdk/chat/index.ts b/src/sdk/chat/index.ts index 44ed1773..a3fdca6c 100644 --- a/src/sdk/chat/index.ts +++ b/src/sdk/chat/index.ts @@ -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; +export type ChatThread = z.infer; -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( - `/api/chat/?${thread ? `thread=${thread}&` : ""}q=${encodeURIComponent( - message, - )}`, +): Promise { + const res = await fetch( + `/api/chat/?thread=${thread}&q=${encodeURIComponent(message)}`, ); + + return await res.json(); } diff --git a/src/tailwind.config.ts b/src/tailwind.config.ts index 53e3e984..239f3a3f 100644 --- a/src/tailwind.config.ts +++ b/src/tailwind.config.ts @@ -17,6 +17,7 @@ export default { gridTemplateRows: { "footer-mobile": "repeat(2, auto)", "footer-desktop": "1fr", + "message-box": "1fr auto", }, }, }, diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index e38c8511..e1e2f715 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -5,19 +5,25 @@ import { useCallback, useMemo, useState } from "preact/hooks"; * A suspense-enabled hook. */ -export function useFetchData(url: string): T | undefined { - const fetchJson = useCallback(async () => { +export function useFetchData(url: string | undefined): T | undefined { + const fetchJson = useCallback(async (): Promise => { + 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(promise: Promise): T | undefined { +export function usePromise(promise: Promise): T | undefined { const status = useSignal<"pending" | "fulfilled" | "rejected">("pending"); const result = useSignal(undefined); const error = useSignal(undefined); diff --git a/src/utils/icons.ts b/src/utils/icons.ts index 40c32d50..ec0698e4 100644 --- a/src/utils/icons.ts +++ b/src/utils/icons.ts @@ -12,6 +12,7 @@ import IconInfoCircleComponent from "$tabler_icons/info-circle.tsx"; import IconLemon2Component from "$tabler_icons/lemon-2.tsx"; import IconLinkComponent from "$tabler_icons/link.tsx"; import IconMessageChatbotComponent from "$tabler_icons/message-chatbot.tsx"; +import IconSendComponent from "$tabler_icons/send.tsx"; import IconSolarPanel2Component from "$tabler_icons/solar-panel-2.tsx"; import IconSolarPanelComponent from "$tabler_icons/solar-panel.tsx"; @@ -99,3 +100,10 @@ export const IconLink: Icon = IconLinkComponent; * It's a smiling robot! */ export const IconMessageChatbot: Icon = IconMessageChatbotComponent; + +/** + * A send icon. + * + * It's a paper airplaneQ + */ +export const IconSend: Icon = IconSendComponent; diff --git a/src/utils/indexeddb.ts b/src/utils/indexeddb.ts new file mode 100644 index 00000000..b5d8f9ad --- /dev/null +++ b/src/utils/indexeddb.ts @@ -0,0 +1,39 @@ +import { IS_BROWSER } from "$fresh/runtime.ts"; +import { get, set } from "idb-keyval"; +import { type Inputs, useCallback } from "preact/hooks"; +import { usePromise } from "./hooks.ts"; + +/** + * Use a value from the IndexedDB. + * If the value doesn't exist, set it to the default. + * + * @remarks + * Unfortunately, this can't distinguish between nonexistent values and existent undefined values! + * Use with caution! + * + * @param key - The key of the item to fetch. + * @param def - A function returning a promise that'll resolve to the default. + */ +export function useIndexedDB( + key: string, + inputs: Inputs, + def?: () => Promise, +): T | undefined { + if (!IS_BROWSER) { + throw new Error("This is browser-only!"); + } + + const callback = useCallback(async () => { + const val = await get(key); + + if (val === undefined && def !== undefined) { + const defaultValue = await def(); + await set(key, defaultValue); + return defaultValue; + } + + return val; + }, [key, def, inputs]); + + return usePromise(callback()); +} diff --git a/src/utils/openai-schemas.ts b/src/utils/openai-schemas.ts index 2f8cee33..b9abd5b5 100644 --- a/src/utils/openai-schemas.ts +++ b/src/utils/openai-schemas.ts @@ -1,9 +1,9 @@ -import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts"; +import type { Message } 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( +export const messageSchema = z.custom( (val) => z.object({}).safeParse(val).success, ); diff --git a/src/utils/openai.ts b/src/utils/openai.ts index d64e2ae1..9e03c967 100644 --- a/src/utils/openai.ts +++ b/src/utils/openai.ts @@ -1,8 +1,8 @@ import { OpenAI } from "openai"; -import type { MessageContentText } from "openai/resources/beta/threads/messages/messages.ts"; +import type { Message } from "openai/resources/beta/threads/messages/messages.ts"; import type { Thread } from "openai/resources/beta/threads/threads.ts"; -let client: OpenAI; +export let client: OpenAI; let ASSISTANT_ID: string; // Try to connect to the real OpenAI API, if it fails, use the mock API. @@ -30,11 +30,11 @@ export async function newThread(): Promise { return await client.beta.threads.create(); } -export async function ask( +export async function* ask( q: string, thread_id: string, assistant_id: string = ASSISTANT_ID, -): Promise { +): AsyncGenerator { await client.beta.threads.messages.create(thread_id, { role: "user", content: q, @@ -43,6 +43,7 @@ export async function ask( assistant_id, }); + // TODO: Poll on the client while ( run.status === "in_progress" || run.status === "queued" || @@ -57,11 +58,7 @@ export async function ask( run = await client.beta.threads.runs.retrieve(thread_id, run.id); } - const messages = await client.beta.threads.messages.list(thread_id); - const lastMessage = messages.data[0]?.content[0]; - - if (lastMessage?.type !== "text") { - throw new Error("We don't support images."); + for await (const message of client.beta.threads.messages.list(thread_id)) { + yield message; } - return lastMessage; } diff --git a/src/utils/openai/ref.ts b/src/utils/openai/ref.ts new file mode 100644 index 00000000..03af5474 --- /dev/null +++ b/src/utils/openai/ref.ts @@ -0,0 +1,55 @@ +import type { Annotation } from "openai/resources/beta/threads/messages/messages.ts"; +import { client } from "../openai.ts"; + +export async function formatRefs( + threadId: string, + messageId: string, +): Promise { + // Retrieve the message object + const message = await client.beta.threads.messages.retrieve( + threadId, + messageId, + ); + + if (message.content[0]?.type !== "text") { + throw new Error(); + } + + // Extract the message content + const messageContent = message.content[0]?.text; + const annotations = messageContent.annotations; + const citations: string[] = []; + + // Define an async function that handles each annotation + async function handleAnnotation( + annotation: Annotation, + index: number, + ): Promise { + switch (annotation.type) { + case "file_citation": { + const fileCitation = annotation.file_citation; + const citedFile = await client.files.retrieve(fileCitation.file_id); + citations.push( + `[${index}] ${fileCitation.quote} from ${citedFile.filename}`, + ); + break; + } + case "file_path": { + const filePath = annotation.file_path; + const citedFile = await client.files.retrieve(filePath.file_id); + citations.push( + `[${index}] Click to download ${citedFile.filename}`, + ); + break; + } + } + } + + // Map over the annotations to create an array of promises + await Promise.all(annotations.map(handleAnnotation)); + + // Add footnotes to the end of the message before displaying to user + messageContent.value += `\n${citations.join("\n")}`; + + return messageContent.value; +} From faf501d251e3c67cb25ce8a862bc62835e9a80f7 Mon Sep 17 00:00:00 2001 From: Eli <88557639+lishaduck@users.noreply.github.com> Date: Thu, 21 Mar 2024 11:36:01 -0500 Subject: [PATCH 5/5] fix: todos --- src/components/Footer.tsx | 1 - src/utils/openai.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index c607c74a..d9a365eb 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -137,7 +137,6 @@ function RenderCategory(props: Menu): JSX.Element { * @returns The rendered category header. */ // TODO(lishaduck): Add a 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 (