Initial commit from Create Llama

This commit is contained in:
2024-08-08 18:33:08 +08:00
commit 4923337038
97 changed files with 5378 additions and 0 deletions
@@ -0,0 +1,25 @@
import { User2 } from "lucide-react";
import Image from "next/image";
export default function ChatAvatar({ role }: { role: string }) {
if (role === "user") {
return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-background shadow">
<User2 className="h-4 w-4" />
</div>
);
}
return (
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white shadow">
<Image
className="rounded-md"
src="/llama.png"
alt="Llama Logo"
width={24}
height={24}
priority
/>
</div>
);
}
@@ -0,0 +1,50 @@
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "../../button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../../collapsible";
import { EventData } from "../index";
export function ChatEvents({
data,
isLoading,
}: {
data: EventData[];
isLoading: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const buttonLabel = isOpen ? "Hide events" : "Show events";
const EventIcon = isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
);
return (
<div className="border-l-2 border-indigo-400 pl-2">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button variant="secondary" className="space-x-2">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<span>{buttonLabel}</span>
{EventIcon}
</Button>
</CollapsibleTrigger>
<CollapsibleContent asChild>
<div className="mt-4 text-sm space-y-2">
{data.map((eventItem, index) => (
<div className="whitespace-break-spaces" key={index}>
{eventItem.title}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
@@ -0,0 +1,13 @@
import { DocumentPreview } from "../../document-preview";
import { DocumentFileData } from "../index";
export function ChatFiles({ data }: { data: DocumentFileData }) {
if (!data.files.length) return null;
return (
<div className="flex gap-2 items-center">
{data.files.map((file) => (
<DocumentPreview key={file.id} file={file} />
))}
</div>
);
}
@@ -0,0 +1,17 @@
import Image from "next/image";
import { type ImageData } from "../index";
export function ChatImage({ data }: { data: ImageData }) {
return (
<div className="rounded-md max-w-[200px] shadow-md">
<Image
src={data.url}
width={0}
height={0}
sizes="100vw"
style={{ width: "100%", height: "auto" }}
alt=""
/>
</div>
);
}
@@ -0,0 +1,123 @@
import { Check, Copy } from "lucide-react";
import { useMemo } from "react";
import { Button } from "../../button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../../hover-card";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import { SourceData } from "../index";
import PdfDialog from "../widgets/PdfDialog";
const SCORE_THRESHOLD = 0.3;
function SourceNumberButton({ index }: { index: number }) {
return (
<div className="text-xs w-5 h-5 rounded-full bg-gray-100 mb-2 flex items-center justify-center hover:text-white hover:bg-primary hover:cursor-pointer">
{index + 1}
</div>
);
}
type NodeInfo = {
id: string;
url?: string;
};
export function ChatSources({ data }: { data: SourceData }) {
const sources: NodeInfo[] = useMemo(() => {
// aggregate nodes by url or file_path (get the highest one by score)
const nodesByPath: { [path: string]: NodeInfo } = {};
data.nodes
.filter((node) => (node.score ?? 1) > SCORE_THRESHOLD)
.sort((a, b) => (b.score ?? 1) - (a.score ?? 1))
.forEach((node) => {
const nodeInfo = {
id: node.id,
url: node.url,
};
const key = nodeInfo.url ?? nodeInfo.id; // use id as key for UNKNOWN type
if (!nodesByPath[key]) {
nodesByPath[key] = nodeInfo;
}
});
return Object.values(nodesByPath);
}, [data.nodes]);
if (sources.length === 0) return null;
return (
<div className="space-x-2 text-sm">
<span className="font-semibold">Sources:</span>
<div className="inline-flex gap-1 items-center">
{sources.map((nodeInfo: NodeInfo, index: number) => {
if (nodeInfo.url?.endsWith(".pdf")) {
return (
<PdfDialog
key={nodeInfo.id}
documentId={nodeInfo.id}
url={nodeInfo.url!}
trigger={<SourceNumberButton index={index} />}
/>
);
}
return (
<div key={nodeInfo.id}>
<HoverCard>
<HoverCardTrigger>
<SourceNumberButton index={index} />
</HoverCardTrigger>
<HoverCardContent className="w-[320px]">
<NodeInfo nodeInfo={nodeInfo} />
</HoverCardContent>
</HoverCard>
</div>
);
})}
</div>
</div>
);
}
function NodeInfo({ nodeInfo }: { nodeInfo: NodeInfo }) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 1000 });
if (nodeInfo.url) {
// this is a node generated by the web loader or file loader,
// add a link to view its URL and a button to copy the URL to the clipboard
return (
<div className="flex items-center my-2">
<a
className="hover:text-blue-900 truncate"
href={nodeInfo.url}
target="_blank"
>
<span>{nodeInfo.url}</span>
</a>
<Button
onClick={() => copyToClipboard(nodeInfo.url!)}
size="icon"
variant="ghost"
className="h-12 w-12 shrink-0"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
);
}
// node generated by unknown loader, implement renderer by analyzing logged out metadata
return (
<p>
Sorry, unknown node type. Please add a new renderer in the NodeInfo
component.
</p>
);
}
@@ -0,0 +1,32 @@
import { useState } from "react";
import { ChatHandler, SuggestedQuestionsData } from "..";
export function SuggestedQuestions({
questions,
append,
}: {
questions: SuggestedQuestionsData;
append: Pick<ChatHandler, "append">["append"];
}) {
const [showQuestions, setShowQuestions] = useState(questions.length > 0);
return (
showQuestions &&
append !== undefined && (
<div className="flex flex-col space-y-2">
{questions.map((question, index) => (
<a
key={index}
onClick={() => {
append({ role: "user", content: question });
setShowQuestions(false);
}}
className="text-sm italic hover:underline cursor-pointer"
>
{"->"} {question}
</a>
))}
</div>
)
);
}
@@ -0,0 +1,26 @@
import { ToolData } from "../index";
import { WeatherCard, WeatherData } from "../widgets/WeatherCard";
// TODO: If needed, add displaying more tool outputs here
export default function ChatTools({ data }: { data: ToolData }) {
if (!data) return null;
const { toolCall, toolOutput } = data;
if (toolOutput.isError) {
return (
<div className="border-l-2 border-red-400 pl-2">
There was an error when calling the tool {toolCall.name} with input:{" "}
<br />
{JSON.stringify(toolCall.input)}
</div>
);
}
switch (toolCall.name) {
case "get_weather_information":
const weatherData = toolOutput.output as unknown as WeatherData;
return <WeatherCard data={weatherData} />;
default:
return null;
}
}
@@ -0,0 +1,139 @@
"use client";
import { Check, Copy, Download } from "lucide-react";
import { FC, memo } from "react";
import { Prism, SyntaxHighlighterProps } from "react-syntax-highlighter";
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
import { Button } from "../../button";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
// TODO: Remove this when @type/react-syntax-highlighter is updated
const SyntaxHighlighter = Prism as unknown as FC<SyntaxHighlighterProps>;
interface Props {
language: string;
value: string;
}
interface languageMap {
[key: string]: string | undefined;
}
export const programmingLanguages: languageMap = {
javascript: ".js",
python: ".py",
java: ".java",
c: ".c",
cpp: ".cpp",
"c++": ".cpp",
"c#": ".cs",
ruby: ".rb",
php: ".php",
swift: ".swift",
"objective-c": ".m",
kotlin: ".kt",
typescript: ".ts",
go: ".go",
perl: ".pl",
rust: ".rs",
scala: ".scala",
haskell: ".hs",
lua: ".lua",
shell: ".sh",
sql: ".sql",
html: ".html",
css: ".css",
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
};
export const generateRandomString = (length: number, lowercase = false) => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
let result = "";
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return lowercase ? result.toLowerCase() : result;
};
const CodeBlock: FC<Props> = memo(({ language, value }) => {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
const downloadAsFile = () => {
if (typeof window === "undefined") {
return;
}
const fileExtension = programmingLanguages[language] || ".file";
const suggestedFileName = `file-${generateRandomString(
3,
true,
)}${fileExtension}`;
const fileName = window.prompt("Enter file name" || "", suggestedFileName);
if (!fileName) {
// User pressed cancel on prompt.
return;
}
const blob = new Blob([value], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.download = fileName;
link.href = url;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const onCopy = () => {
if (isCopied) return;
copyToClipboard(value);
};
return (
<div className="codeblock relative w-full bg-zinc-950 font-sans">
<div className="flex w-full items-center justify-between bg-zinc-800 px-6 py-2 pr-4 text-zinc-100">
<span className="text-xs lowercase">{language}</span>
<div className="flex items-center space-x-1">
<Button variant="ghost" onClick={downloadAsFile} size="icon">
<Download />
<span className="sr-only">Download</span>
</Button>
<Button variant="ghost" size="icon" onClick={onCopy}>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
<span className="sr-only">Copy code</span>
</Button>
</div>
</div>
<SyntaxHighlighter
language={language}
style={coldarkDark}
PreTag="div"
showLineNumbers
customStyle={{
width: "100%",
background: "transparent",
padding: "1.5rem 1rem",
borderRadius: "0.5rem",
}}
codeTagProps={{
style: {
fontSize: "0.9rem",
fontFamily: "var(--font-mono)",
},
}}
>
{value}
</SyntaxHighlighter>
</div>
);
});
CodeBlock.displayName = "CodeBlock";
export { CodeBlock };
@@ -0,0 +1,156 @@
import { Check, Copy } from "lucide-react";
import { Message } from "ai";
import { Fragment } from "react";
import { Button } from "../../button";
import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
import {
ChatHandler,
DocumentFileData,
EventData,
ImageData,
MessageAnnotation,
MessageAnnotationType,
SourceData,
SuggestedQuestionsData,
ToolData,
getAnnotationData,
} from "../index";
import ChatAvatar from "./chat-avatar";
import { ChatEvents } from "./chat-events";
import { ChatFiles } from "./chat-files";
import { ChatImage } from "./chat-image";
import { ChatSources } from "./chat-sources";
import { SuggestedQuestions } from "./chat-suggestedQuestions";
import ChatTools from "./chat-tools";
import Markdown from "./markdown";
type ContentDisplayConfig = {
order: number;
component: JSX.Element | null;
};
function ChatMessageContent({
message,
isLoading,
append,
}: {
message: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
}) {
const annotations = message.annotations as MessageAnnotation[] | undefined;
if (!annotations?.length) return <Markdown content={message.content} />;
const imageData = getAnnotationData<ImageData>(
annotations,
MessageAnnotationType.IMAGE,
);
const contentFileData = getAnnotationData<DocumentFileData>(
annotations,
MessageAnnotationType.DOCUMENT_FILE,
);
const eventData = getAnnotationData<EventData>(
annotations,
MessageAnnotationType.EVENTS,
);
const sourceData = getAnnotationData<SourceData>(
annotations,
MessageAnnotationType.SOURCES,
);
const toolData = getAnnotationData<ToolData>(
annotations,
MessageAnnotationType.TOOLS,
);
const suggestedQuestionsData = getAnnotationData<SuggestedQuestionsData>(
annotations,
MessageAnnotationType.SUGGESTED_QUESTIONS,
);
const contents: ContentDisplayConfig[] = [
{
order: 1,
component: imageData[0] ? <ChatImage data={imageData[0]} /> : null,
},
{
order: -3,
component:
eventData.length > 0 ? (
<ChatEvents isLoading={isLoading} data={eventData} />
) : null,
},
{
order: 2,
component: contentFileData[0] ? (
<ChatFiles data={contentFileData[0]} />
) : null,
},
{
order: -1,
component: toolData[0] ? <ChatTools data={toolData[0]} /> : null,
},
{
order: 0,
component: <Markdown content={message.content} />,
},
{
order: 3,
component: sourceData[0] ? <ChatSources data={sourceData[0]} /> : null,
},
{
order: 4,
component: suggestedQuestionsData[0] ? (
<SuggestedQuestions
questions={suggestedQuestionsData[0]}
append={append}
/>
) : null,
},
];
return (
<div className="flex-1 gap-4 flex flex-col">
{contents
.sort((a, b) => a.order - b.order)
.map((content, index) => (
<Fragment key={index}>{content.component}</Fragment>
))}
</div>
);
}
export default function ChatMessage({
chatMessage,
isLoading,
append,
}: {
chatMessage: Message;
isLoading: boolean;
append: Pick<ChatHandler, "append">["append"];
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
return (
<div className="flex items-start gap-4 pr-5 pt-5">
<ChatAvatar role={chatMessage.role} />
<div className="group flex flex-1 justify-between gap-2">
<ChatMessageContent
message={chatMessage}
isLoading={isLoading}
append={append}
/>
<Button
onClick={() => copyToClipboard(chatMessage.content)}
size="icon"
variant="ghost"
className="h-8 w-8 opacity-0 group-hover:opacity-100"
>
{isCopied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
import "katex/dist/katex.min.css";
import { FC, memo } from "react";
import ReactMarkdown, { Options } from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "./codeblock";
const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className,
);
const preprocessLaTeX = (content: string) => {
// Replace block-level LaTeX delimiters \[ \] with $$ $$
const blockProcessedContent = content.replace(
/\\\[([\s\S]*?)\\\]/g,
(_, equation) => `$$${equation}$$`,
);
// Replace inline LaTeX delimiters \( \) with $ $
const inlineProcessedContent = blockProcessedContent.replace(
/\\\[([\s\S]*?)\\\]/g,
(_, equation) => `$${equation}$`,
);
return inlineProcessedContent;
};
const preprocessMedia = (content: string) => {
// Remove `sandbox:` from the beginning of the URL
// to fix OpenAI's models issue appending `sandbox:` to the relative URL
return content.replace(/(sandbox|attachment|snt):/g, "");
};
const preprocessContent = (content: string) => {
return preprocessMedia(preprocessLaTeX(content));
};
export default function Markdown({ content }: { content: string }) {
const processedContent = preprocessContent(content);
return (
<MemoizedReactMarkdown
className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 break-words custom-markdown"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex as any]}
components={{
p({ children }) {
return <p className="mb-2 last:mb-0">{children}</p>;
},
code({ node, inline, className, children, ...props }) {
if (children.length) {
if (children[0] == "▍") {
return (
<span className="mt-1 animate-pulse cursor-default"></span>
);
}
children[0] = (children[0] as string).replace("`▍`", "▍");
}
const match = /language-(\w+)/.exec(className || "");
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<CodeBlock
key={Math.random()}
language={(match && match[1]) || ""}
value={String(children).replace(/\n$/, "")}
{...props}
/>
);
},
}}
>
{processedContent}
</MemoizedReactMarkdown>
);
}