chat UI and markdown renderer ⚡

This commit is contained in:
Dhravya 2024-06-12 23:37:06 -05:00
parent f59cedec7e
commit 5569aa278c
15 changed files with 574 additions and 25834 deletions

View file

@ -6,15 +6,12 @@ To generate your answer:
- Carefully analyze the question and identify the key information needed to address it
- Locate the specific parts of each context that contain this key information
- Compare the relevance scores of the provided contexts
- In the <justification> tags, provide a brief justification for which context(s) are more relevant to answering the question based on the scores
- Concisely summarize the relevant information from the higher-scoring context(s) in your own words
- Provide a direct answer to the question
- Use markdown formatting in your answer, including bold, italics, and bullet points as appropriate to improve readability and highlight key points
- Give detailed and accurate responses for things like 'write a blog' or long-form questions.
- The normalisedScore is a value in which the scores are 'balanced' to give a better representation of the relevance of the context, between 1 and 100, out of the top 10 results
Provide your justification between <justification> tags and your final answer between <answer> tags, formatting both in markdown.
- provide your justification in the end, in a <justification> </justification> tag
If no context is provided, introduce yourself and explain that the user can save content which will allow you to answer questions about that content in the future. Do not provide an answer if no context is provided.`;
export const template = ({ contexts, question }) => {

View file

@ -0,0 +1,90 @@
import React, { useRef, useState } from "react";
const CodeBlock = ({
lang,
codeChildren,
}: {
lang: string;
codeChildren: React.ReactNode & React.ReactNode[];
}) => {
const codeRef = useRef<HTMLElement>(null);
return (
<div className="bg-black rounded-md">
<CodeBar lang={lang} codeRef={codeRef} />
<div className="p-4 overflow-y-auto">
<code ref={codeRef} className={`!whitespace-pre hljs language-${lang}`}>
{codeChildren}
</code>
</div>
</div>
);
};
const CodeBar = React.memo(
({
lang,
codeRef,
}: {
lang: string;
codeRef: React.RefObject<HTMLElement>;
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
return (
<div className="flex items-center relative text-gray-200 bg-gray-800 px-4 py-2 text-xs font-sans">
<span className="">{lang}</span>
<button
className="flex ml-auto gap-2"
aria-label="copy codeblock"
onClick={async () => {
const codeString = codeRef.current?.textContent;
if (codeString)
navigator.clipboard.writeText(codeString).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 3000);
});
}}
>
{isCopied ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75"
/>
</svg>
Copied!
</>
) : (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
/>
</svg>
Copy code
</>
)}
</button>
</div>
);
},
);
export default CodeBlock;

View file

@ -1 +0,0 @@
"use server";

View file

@ -6,29 +6,92 @@ import QueryInput from "../home/queryinput";
import { cn } from "@repo/ui/lib/utils";
import { motion } from "framer-motion";
import { useRouter } from "next/navigation";
import { ChatHistory } from "@repo/shared-types";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@repo/ui/shadcn/accordion";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import { code, p } from "./markdownRenderHelpers";
import { codeLanguageSubset } from "@/app/helpers/constants";
function ChatWindow({ q }: { q: string }) {
function ChatWindow({
q,
spaces,
}: {
q: string;
spaces: { id: string; name: string }[];
}) {
const [layout, setLayout] = useState<"chat" | "initial">("initial");
const [chatHistory, setChatHistory] = useState<ChatHistory[]>([
{
question: q,
answer: {
parts: [
// {
// text: `It seems like there might be a typo in your question. Could you please clarify or provide more context? If you meant "interesting," please let me know what specific information or topic you find interesting, and I can help you with that.`,
// },
],
sources: [],
},
},
]);
const router = useRouter();
const getAnswer = async (query: string, spaces: string[]) => {
const resp = await fetch(`/api/chat?q=${query}&spaces=${spaces}`, {
method: "POST",
body: JSON.stringify({ chatHistory }),
});
const reader = resp.body?.getReader();
let done = false;
let result = "";
while (!done && reader) {
const { value, done: d } = await reader.read();
done = d;
setChatHistory((prevChatHistory) => {
const newChatHistory = [...prevChatHistory];
const lastAnswer = newChatHistory[newChatHistory.length - 1];
if (!lastAnswer) return prevChatHistory;
lastAnswer.answer.parts.push({ text: new TextDecoder().decode(value) });
return newChatHistory;
});
}
console.log(result);
};
useEffect(() => {
if (q !== "") {
if (q.trim().length > 0) {
getAnswer(
q,
spaces.map((s) => s.id),
);
setTimeout(() => {
setLayout("chat");
}, 300);
} else {
router.push("/home");
}
}, [q]);
}, []);
return (
<div>
<div className="h-full">
<AnimatePresence mode="popLayout">
{layout === "initial" ? (
<motion.div
exit={{ opacity: 0 }}
key="initial"
className="max-w-3xl flex mx-auto w-full flex-col"
className="max-w-3xl h-full justify-center items-center flex mx-auto w-full flex-col"
>
<div className="w-full h-96">
<QueryInput initialQuery={q} initialSpaces={[]} disabled />
@ -36,16 +99,111 @@ function ChatWindow({ q }: { q: string }) {
</motion.div>
) : (
<div
className="max-w-3xl flex mx-auto w-full flex-col mt-8"
className="max-w-3xl flex mx-auto w-full flex-col mt-24"
key="chat"
>
<h2
className={cn(
"transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl",
)}
>
{q}
</h2>
{chatHistory.map((chat, idx) => (
<div
key={idx}
className={`mt-8 ${idx != chatHistory.length - 1 ? "pb-2 border-b" : ""}`}
>
<h2
className={cn(
"text-white transition-all transform translate-y-0 opacity-100 duration-500 ease-in-out font-semibold text-2xl",
)}
>
{chat.question}
</h2>
<div className="flex flex-col gap-2 mt-2">
<div
className={`${chat.answer.sources.length > 0 || chat.answer.parts.length === 0 ? "flex" : "hidden"}`}
>
<Accordion
defaultValue={
idx === chatHistory.length - 1 ? "memories" : ""
}
type="single"
collapsible
>
<AccordionItem value="memories">
<AccordionTrigger className="text-foreground-menu">
Related Memories
</AccordionTrigger>
{/* TODO: fade out content on the right side, the fade goes away when the user scrolls */}
<AccordionContent
className="relative flex gap-2 max-w-3xl overflow-auto no-scrollbar"
defaultChecked
>
{/* Loading state */}
{chat.answer.sources.length > 0 ||
(chat.answer.parts.length === 0 && (
<>
{[1, 2, 3, 4].map((_, idx) => (
<div
key={`loadingState-${idx}`}
className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72 animate-pulse"
>
<div className="bg-slate-700 h-2 rounded-full w-1/2"></div>
<div className="bg-slate-700 h-2 rounded-full w-full"></div>
</div>
))}
</>
))}
{chat.answer.sources.map((source, idx) => (
<div
key={idx}
className="rounded-xl bg-secondary p-4 flex flex-col gap-2 min-w-72"
>
<div className="text-foreground-menu">
{source.type}
</div>
<div>{source.title}</div>
</div>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* Summary */}
<div>
<div className="text-foreground-menu py-2">Summary</div>
<div className="text-base">
{chat.answer.parts.length === 0 && (
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-3 py-1">
<div className="h-2 bg-slate-700 rounded"></div>
<div className="h-2 bg-slate-700 rounded"></div>
</div>
</div>
)}
<Markdown
remarkPlugins={[remarkGfm, [remarkMath]]}
rehypePlugins={[
rehypeKatex,
[
rehypeHighlight,
{
detect: true,
ignoreMissing: true,
subset: codeLanguageSubset,
},
],
]}
components={{
code: code as any,
p: p as any,
}}
className="flex flex-col gap-2"
>
{chat.answer.parts.map((part) => part.text).join("")}
</Markdown>
</div>
</div>
</div>
</div>
))}
</div>
)}
</AnimatePresence>

View file

@ -0,0 +1,25 @@
import { DetailedHTMLProps, HTMLAttributes, memo } from "react";
import { ExtraProps } from "react-markdown";
import CodeBlock from "./CodeBlock";
export const code = memo((props: JSX.IntrinsicElements["code"]) => {
const { className, children } = props;
const match = /language-(\w+)/.exec(className || "");
const lang = match && match[1];
return <CodeBlock lang={lang || "text"} codeChildren={children as any} />;
});
export const p = memo(
(
props?: Omit<
DetailedHTMLProps<
HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
>,
"ref"
>,
) => {
return <p className="whitespace-pre-wrap">{props?.children}</p>;
},
);

View file

@ -1,5 +1,7 @@
import ChatWindow from "./chatWindow";
import { chatSearchParamsCache } from "../../helpers/lib/searchParams";
// @ts-expect-error
await import("katex/dist/katex.min.css");
function Page({
searchParams,
@ -10,7 +12,7 @@ function Page({
console.log(spaces);
return <ChatWindow q={q} />;
return <ChatWindow q={q} spaces={[]} />;
}
export default Page;

View file

@ -1,6 +1,11 @@
import { type NextRequest } from "next/server";
import { ChatHistory } from "@repo/shared-types";
import {
ChatHistory,
ChatHistoryZod,
convertChatHistoryList,
} from "@repo/shared-types";
import { ensureAuth } from "../ensureAuth";
import { z } from "zod";
export const runtime = "edge";
@ -15,59 +20,69 @@ export async function POST(req: NextRequest) {
return new Response("Missing BACKEND_SECURITY_KEY", { status: 500 });
}
const query = new URL(req.url).searchParams.get("q");
const spaces = new URL(req.url).searchParams.get("spaces");
const url = new URL(req.url);
const sourcesOnly =
new URL(req.url).searchParams.get("sourcesOnly") ?? "false";
const query = url.searchParams.get("q");
const spaces = url.searchParams.get("spaces");
const chatHistory = (await req.json()) as {
chatHistory: ChatHistory[];
};
const sourcesOnly = url.searchParams.get("sourcesOnly") ?? "false";
console.log("CHathistory", chatHistory);
const chatHistory = await req.json();
if (!query) {
if (!query || query.trim.length < 0) {
return new Response(JSON.stringify({ message: "Invalid query" }), {
status: 400,
});
}
try {
const resp = await fetch(
`https://cf-ai-backend.dhravya.workers.dev/chat?q=${query}&user=${session.user.email ?? session.user.name}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`,
{
headers: {
"X-Custom-Auth-Key": process.env.BACKEND_SECURITY_KEY!,
},
method: "POST",
body: JSON.stringify({
chatHistory: chatHistory.chatHistory ?? [],
}),
},
const validated = z
.object({ chatHistory: z.array(ChatHistoryZod) })
.safeParse(chatHistory ?? []);
if (!validated.success) {
return new Response(
JSON.stringify({
message: "Invalid chat history",
error: validated.error,
}),
{ status: 400 },
);
}
console.log("sourcesOnly", sourcesOnly);
const modelCompatible = await convertChatHistoryList(
validated.data.chatHistory,
);
if (sourcesOnly == "true") {
const data = await resp.json();
console.log("data", data);
return new Response(JSON.stringify(data), { status: 200 });
}
const resp = await fetch(
`https://new-cf-ai-backend.dhravya.workers.dev/api/chat?query=${query}&user=${session.user.email}&sourcesOnly=${sourcesOnly}&spaces=${spaces}`,
{
headers: {
Authorization: `Bearer ${process.env.BACKEND_SECURITY_KEY}`,
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
chatHistory: modelCompatible,
}),
},
);
if (resp.status !== 200 || !resp.ok) {
const errorData = await resp.json();
console.log(errorData);
return new Response(
JSON.stringify({ message: "Error in CF function", error: errorData }),
{ status: resp.status },
);
}
console.log("sourcesOnly", sourcesOnly);
// Stream the response back to the client
const { readable, writable } = new TransformStream();
resp && resp.body!.pipeTo(writable);
if (sourcesOnly == "true") {
const data = await resp.json();
console.log("data", data);
return new Response(JSON.stringify(data), { status: 200 });
}
return new Response(readable, { status: 200 });
} catch {}
if (resp.status !== 200 || !resp.ok) {
const errorData = await resp.text();
console.log(errorData);
return new Response(
JSON.stringify({ message: "Error in CF function", error: errorData }),
{ status: resp.status },
);
}
return new Response(resp.body, { status: 200 });
}

View file

@ -0,0 +1,37 @@
export const codeLanguageSubset = [
"python",
"javascript",
"java",
"go",
"bash",
"c",
"cpp",
"csharp",
"css",
"diff",
"graphql",
"json",
"kotlin",
"less",
"lua",
"makefile",
"markdown",
"objectivec",
"perl",
"php",
"php-template",
"plaintext",
"python-repl",
"r",
"ruby",
"rust",
"scss",
"shell",
"sql",
"swift",
"typescript",
"vbnet",
"wasm",
"xml",
"yaml",
];

View file

@ -1,6 +0,0 @@
GOOGLE_CLIENT_ID="-"
GOOGLE_CLIENT_SECRET="-"
NEXTAUTH_SECRET='nextauthsecret'
DATABASE_URL='database.sqlite'
NEXTAUTH_URL='http://localhost:3000'
BACKEND_SECURITY_KEY='veryrandomsecuritykey'

View file

@ -4,6 +4,7 @@ import { setupDevPlatform } from "@cloudflare/next-on-pages/next-dev";
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@repo/ui"],
reactStrictMode: false,
};
export default MillionLint.next({
rsc: true,

25760
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -53,6 +53,7 @@
"@iarna/toml": "^2.2.5",
"@langchain/cloudflare": "^0.0.6",
"@million/lint": "^1.0.0-rc.18",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
@ -68,13 +69,20 @@
"compromise": "^14.13.0",
"drizzle-orm": "^0.30.10",
"framer-motion": "^11.2.6",
"katex": "^0.16.10",
"lucide-react": "^0.379.0",
"next-app-theme": "^0.1.10",
"next-auth": "^5.0.0-beta.18",
"random-js": "^2.1.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.51.5",
"react-markdown": "^9.0.1",
"rehype-highlight": "^7.0.0",
"rehype-katex": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sonner": "^1.4.41",
"tailwind-scrollbar": "^3.1.0",
"tldraw": "^2.1.4",
"uploadthing": "^6.10.4",
"zod": "^3.23.8"

View file

@ -1,7 +1,54 @@
export type ChatHistory = {
question: string;
answer: {
parts: { text: string }[];
sources: { isNote: boolean; source: string }[];
};
};
import { z } from "zod";
export const ChatHistoryZod = z.object({
question: z.string(),
answer: z.object({
parts: z.array(z.object({ text: z.string() })),
sources: z.array(
z.object({
type: z.enum(["note", "page", "tweet"]),
source: z.string(),
title: z.string(),
content: z.string(),
}),
),
}),
});
export type ChatHistory = z.infer<typeof ChatHistoryZod>;
export const ModelCompatibleChatHistoryZod = z.array(
z.object({
role: z.union([
z.literal("user"),
z.literal("assistant"),
z.literal("system"),
]),
content: z.string(),
}),
);
export type ModelCompatibleChatHistory = z.infer<
typeof ModelCompatibleChatHistoryZod
>;
export function convertChatHistoryList(
chatHistoryList: ChatHistory[],
): ModelCompatibleChatHistory {
let convertedChats: ModelCompatibleChatHistory = [];
chatHistoryList.forEach((chat) => {
convertedChats.push(
{
role: "user",
content: chat.question,
},
{
role: "assistant",
content: chat.answer.parts.map((part) => part.text).join(" "),
},
);
});
return convertedChats;
}

View file

@ -49,6 +49,42 @@ body {
align-items: center;
justify-content: center;
}
.markdown table {
--tw-border-spacing-x: 0px;
--tw-border-spacing-y: 0px;
border-collapse: separate;
border-spacing: var(--tw-border-spacing-x) var(--tw-border-spacing-y);
width: 100%;
}
.markdown th {
background-color: rgba(236, 236, 241, 0.2);
border-bottom-width: 1px;
border-left-width: 1px;
border-top-width: 1px;
padding: 0.25rem 0.75rem;
}
.markdown th:first-child {
border-top-left-radius: 0.375rem;
}
.markdown th:last-child {
border-right-width: 1px;
border-top-right-radius: 0.375rem;
}
.markdown td {
border-bottom-width: 1px;
border-left-width: 1px;
padding: 0.25rem 0.75rem;
}
.markdown td:last-child {
border-right-width: 1px;
}
.markdown tbody tr:last-child td:first-child {
border-bottom-left-radius: 0.375rem;
}
.markdown tbody tr:last-child td:last-child {
border-bottom-right-radius: 0.375rem;
}
}
@layer utilities {
@ -57,6 +93,34 @@ body {
}
}
@layer components {
.markdown ol,
.markdown ul {
display: flex;
flex-direction: column;
padding-left: 1rem;
}
.markdown ol li,
.markdown ol li > p,
.markdown ol ol,
.markdown ol ul,
.markdown ul li,
.markdown ul li > p,
.markdown ul ol,
.markdown ul ul {
margin: 0;
}
.markdown ul li:before {
content: "•";
font-size: 0.875rem;
line-height: 1.25rem;
margin-left: -1rem;
position: absolute;
}
}
.gradient-background {
background: linear-gradient(
150deg,
@ -84,3 +148,62 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #22303d;
}
.no-scrollbar {
/* For WebKit (Safari, Chrome, etc.) */
&::-webkit-scrollbar {
display: none;
}
/* For Firefox */
scrollbar-width: none;
/* For IE and Edge */
-ms-overflow-style: none;
}
:not(pre) > code.hljs,
:not(pre) > code[class*="language-"] {
border-radius: 0.3em;
white-space: normal;
}
.hljs-comment {
color: hsla(0, 0%, 100%, 0.5);
}
.hljs-meta {
color: hsla(0, 0%, 100%, 0.6);
}
.hljs-built_in,
.hljs-class .hljs-title {
color: #e9950c;
}
.hljs-doctag,
.hljs-formula,
.hljs-keyword,
.hljs-literal {
color: #2e95d3;
}
.hljs-addition,
.hljs-attribute,
.hljs-meta-string,
.hljs-regexp,
.hljs-string {
color: #00a67d;
}
.hljs-attr,
.hljs-number,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-pseudo,
.hljs-template-variable,
.hljs-type,
.hljs-variable {
color: #df3079;
}
.hljs-bullet,
.hljs-link,
.hljs-selector-id,
.hljs-symbol,
.hljs-title {
color: #f22c3d;
}

View file

@ -71,7 +71,11 @@ const config = {
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require("tailwind-scrollbar"),
],
} satisfies Config;
export default config;