From 704c43e0d316bd2363ed8cc648cee04caf11e4ca 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] 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 0d203cee..3b5ff077 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 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"; @@ -96,3 +97,10 @@ export const IconCheck: Icon = IconCheckComponent; * 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; +}