Repository: skovy/llm-markdown
Branch: main
Commit: 5f404dfd20f4
Files: 25
Total size: 40.8 KB
Directory structure:
gitextract_7slcflxo/
├── .eslintrc.json
├── .gitignore
├── LICENSE
├── README.md
├── next.config.js
├── package.json
├── postcss.config.js
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── chat/
│ │ │ └── route.ts
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── markdown/
│ │ │ └── page.tsx
│ │ └── page.tsx
│ ├── components/
│ │ ├── assistant-message.tsx
│ │ ├── dialog.tsx
│ │ ├── empty-message.tsx
│ │ ├── message-list.tsx
│ │ ├── message.tsx
│ │ ├── model-dialog.tsx
│ │ ├── nav.tsx
│ │ ├── token-dialog.tsx
│ │ └── user-message.tsx
│ └── hooks/
│ ├── use-local-storage.ts
│ └── use-markdown-processor.tsx
├── tailwind.config.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": "next/core-web-vitals"
}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Spencer Miskoviak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# 📝 LLM Markdown
A [Nextjs](https://nextjs.org) app demonstrating how to display rich-text responses from Large Language Models (LLMs) by prompting and rendering Markdown formatting, Mermaid diagrams, and LaTeX equations.
Read more in this blog post: [Rendering rich responses from LLMs](https://www.skovy.dev/blog/vercel-ai-rendering-markdown)
## Examples
This example is asking for the top grossing movies, structured as a Mermaid pie chart.

This example is asking when vegetables should be planted, structured as a Mermaid Gantt chart.

## Technologies
- [Nextjs](https://nextjs.org)
- [Vercel AI](https://sdk.vercel.ai/docs)
- [`remark`](https://remark.js.org)
- [`mermaid`](https://mermaid.js.org)
- [`latex.js`](https://latex.js.org)
- And more...
## Setup
- Clone the project
- `npm install`
- `npm run dev`
- Open in your browser
- Set your OpenAI API Key
================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig
================================================
FILE: package.json
================================================
{
"name": "nextjs-ai-markdown",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@capsizecss/metrics": "^1.2.0",
"@phosphor-icons/react": "^2.0.10",
"@radix-ui/react-dialog": "^1.0.4",
"@themosaad/tailwindcss-capsize": "^1.0.0",
"@types/node": "20.3.3",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@vercel/analytics": "^1.0.1",
"ai": "2.1.12",
"autoprefixer": "10.4.14",
"classnames": "^2.3.2",
"eslint": "8.44.0",
"eslint-config-next": "13.4.7",
"latex.js": "^0.12.6",
"mermaid": "^10.2.4",
"next": "13.4.7",
"openai-edge": "^1.1.1",
"postcss": "8.4.24",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-keyed-flatten-children": "^2.2.1",
"rehype-highlight": "^6.0.0",
"rehype-react": "^7.2.0",
"remark-gfm": "^3.0.1",
"remark-parse": "^10.0.2",
"remark-rehype": "^10.1.0",
"tailwindcss": "3.3.2",
"typescript": "5.1.6",
"unified": "^10.1.2"
},
"devDependencies": {
"prettier-plugin-organize-imports": "^3.2.2"
}
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
================================================
FILE: src/app/api/chat/route.ts
================================================
import { OpenAIStream, StreamingTextResponse } from "ai";
import { NextResponse } from "next/server";
import {
ChatCompletionRequestMessageRoleEnum,
Configuration,
OpenAIApi,
} from "openai-edge";
export const runtime = "edge";
const SYSTEM_PROMPT = `You are a general answering assistant that can comply with any request.
You always answer the with markdown formatting. You will be penalized if you do not answer with markdown when it would be possible.
The markdown formatting you support: headings, bold, italic, links, tables, lists, code blocks, and blockquotes.
You do not support images and never include images. You will be penalized if you render images.
You also support Mermaid diagrams. You will be penalized if you do not render Mermaid diagrams when it would be possible.
The Mermaid diagrams you support: sequenceDiagram, flowChart, classDiagram, stateDiagram, erDiagram, gantt, journey, gitGraph, pie.
You also support LaTeX equation syntax only in markdown code blocks with the "latex" language.
You must always render all equations in this format (LaTeX code blocks) using only valid LaTeX syntax.
For example:
\`\`\`latex
\\[ F = \\frac{{G \\cdot m_1 \\cdot m_2}}{{r^2}} \\]
\`\`\`latex
`;
export async function POST(req: Request) {
const { messages, token, model = "gpt-3.5-turbo" } = await req.json();
const configuration = new Configuration({ apiKey: token });
const openai = new OpenAIApi(configuration);
try {
const response = await openai.createChatCompletion({
model,
stream: true,
messages: [
{
role: ChatCompletionRequestMessageRoleEnum.System,
content: SYSTEM_PROMPT,
},
...messages,
],
});
if (response.status >= 300) {
const body = await response.json();
return NextResponse.json(
{ error: `OpenAI error encountered: ${body?.error?.message}.` },
{ status: response.status }
);
}
const stream = OpenAIStream(response);
return new StreamingTextResponse(stream);
} catch (e) {
console.error(e);
return NextResponse.json(
{ error: "An unexpected error occurred. Please try again later." },
{ status: 500 }
);
}
}
================================================
FILE: src/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
================================================
FILE: src/app/layout.tsx
================================================
import { Analytics } from "@vercel/analytics/react";
import classNames from "classnames";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const sans = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
const monospace = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata = {
title: "LLM Markdown",
description:
"App demo for rendering rich-text (markdown) from a Large Language Model.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={classNames(
sans.className,
sans.variable,
monospace.variable
)}
>
{children}
<Analytics />
</body>
</html>
);
}
================================================
FILE: src/app/markdown/page.tsx
================================================
"use client";
import { Nav } from "@/components/nav";
import {
MARKDOWN_TEST_MESSAGE,
useMarkdownProcessor,
} from "@/hooks/use-markdown-processor";
export default function Chat() {
const content = useMarkdownProcessor(MARKDOWN_TEST_MESSAGE);
return (
<div className="px-2 lg:px-8 pb-8">
<div className="max-w-2xl w-full mx-auto">
<Nav />
<h1 className="font-sans text-2xl font-semibold text-emerald-950 mb-4 mt-8">
Supported Markdown
</h1>
<p className="font-sans text-base text-emerald-950">
Below is the markdown that is supported in the LLM responses.
</p>
<hr className="border-t-2 border-emerald-100 my-8" />
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{content}
</div>
</div>
</div>
);
}
================================================
FILE: src/app/page.tsx
================================================
"use client";
import { EmptyMessage } from "@/components/empty-message";
import { MessageList } from "@/components/message-list";
import {
ModelDialog,
SUPPORTED_MODELS,
SupportedModels,
} from "@/components/model-dialog";
import { Nav } from "@/components/nav";
import { TokenDialog } from "@/components/token-dialog";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { ANCHOR_CLASS_NAME } from "@/hooks/use-markdown-processor";
import PaperPlaneRight from "@phosphor-icons/react/dist/icons/PaperPlaneRight";
import { useChat } from "ai/react";
import { useState } from "react";
const parseError = (error: Error) => {
try {
return JSON.parse(error.message).error;
} catch (e) {
console.error(e);
return error.message;
}
};
export default function Chat() {
const [tokenOpen, setTokenOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [token, setToken] = useLocalStorage<string | null>("ai-token", null);
const [model, setModel] = useLocalStorage<SupportedModels>(
"ai-model",
SUPPORTED_MODELS[0]
);
const { messages, input, handleInputChange, handleSubmit, error } = useChat({
body: { token, model },
});
return (
<>
<div className="flex flex-col-reverse h-screen overflow-y-scroll">
<div className="mx-auto w-full px-2 lg:px-8 pb-8 flex flex-col stretch gap-8 flex-1">
<Nav />
{messages.length ? (
<MessageList messages={messages} />
) : (
<EmptyMessage />
)}
<form onSubmit={handleSubmit} className="max-w-2xl w-full mx-auto">
{error ? (
<div className="p-3 rounded-lg bg-rose-100 border-2 border-rose-200 mb-3">
<p className="font-sans text-sm text-red text-rose-800">
{parseError(error)}
</p>
</div>
) : null}
<div className="relative">
<input
className="w-full border-2 border-slate-200 rounded-lg p-2 font-sans text-base outline-none ring-offset-0 focus:border-slate-400 focus-visible:ring-2 focus-visible:ring-offset-2 ring-emerald-600 transition-[box-shadow,border-color] pr-10 disabled:opacity-60 disabled:cursor-not-allowed"
value={input}
onChange={handleInputChange}
aria-label="ask a question"
placeholder="Ask a question..."
disabled={!token}
/>
<button
type="submit"
aria-label="send"
className="absolute top-0 right-0 bottom-0 text-emerald-600 outline-none p-3 disabled:text-slate-600 disabled:opacity-60 disabled:cursor-not-allowed hover:text-emerald-800 focus:text-emerald-800 transition-colors"
disabled={!input}
>
<PaperPlaneRight size="1em" />
</button>
</div>
<div className="mt-3 flex gap-2 justify-between">
<div className="flex items-center gap-1">
<button
type="button"
className={ANCHOR_CLASS_NAME}
onClick={() => setModelOpen(true)}
>
<div className="font-sans text-xs font-medium">
Change Model
</div>
</button>
<p className="font-sans text-xs text-slate-500 inline-block">
({model})
</p>
</div>
<button
type="button"
className={ANCHOR_CLASS_NAME}
onClick={() => setTokenOpen(true)}
>
<div className="font-sans text-xs font-medium">
{token ? "Change API Key" : "Set API Key"}
</div>
</button>
</div>
</form>
</div>
</div>
<TokenDialog
// Clear input between opening/closing.
key={tokenOpen.toString()}
open={tokenOpen}
setOpen={setTokenOpen}
setToken={setToken}
/>
<ModelDialog
open={modelOpen}
setOpen={setModelOpen}
model={model}
setModel={setModel}
/>
</>
);
}
================================================
FILE: src/components/assistant-message.tsx
================================================
import { useMarkdownProcessor } from "@/hooks/use-markdown-processor";
interface Props {
children: string;
}
export const AssistantMessage = ({ children }: Props) => {
const content = useMarkdownProcessor(children);
return (
<li className="flex flex-col flex-1 min-w-0 gap-1 ml-6 selection:bg-emerald-300 selection:text-emerald-900">
<p className="font-sans text-xs font-medium text-emerald-700">AI:</p>
<div className="p-2 lg:p-6 border-2 border-emerald-200 rounded-lg bg-emerald-50 text-emerald-900 min-w-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{content}
</div>
</li>
);
};
================================================
FILE: src/components/dialog.tsx
================================================
import X from "@phosphor-icons/react/dist/icons/X";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { ReactNode } from "react";
interface Props {
open: boolean;
setOpen(open: boolean): void;
title: string;
description?: ReactNode;
children: ReactNode;
size?: "xl" | "3xl";
}
export const Dialog = ({
open,
setOpen,
title,
description,
children,
size = "xl",
}: Props) => {
return (
<DialogPrimitive.Root open={open} onOpenChange={setOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 py-8 bg-slate-900/90 grid place-items-center overflow-y-auto z-50">
<DialogPrimitive.Content
className={`bg-white rounded-lg shadow-2xl p-8 w-[90vw] relative ${
size === "3xl" ? "max-w-3xl" : "max-w-xl"
}`}
>
<DialogPrimitive.Title className="font-sans text-lg font-semibold mb-4">
{title}
</DialogPrimitive.Title>
{description ? (
<DialogPrimitive.Description className="font-sans text-sm mb-8">
{description}
</DialogPrimitive.Description>
) : null}
{children}
<DialogPrimitive.Close asChild>
<button
className="absolute top-6 right-6 p-2 rounded-full hover:bg-emerald-50 transition-colors"
aria-label="Close"
>
<X className="w-4 h-4 text-slate-700" />
</button>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
};
================================================
FILE: src/components/empty-message.tsx
================================================
import { ANCHOR_CLASS_NAME } from "@/hooks/use-markdown-processor";
import Link from "next/link";
export const EmptyMessage = () => {
return (
<div className="max-w-2xl my-auto mx-auto w-full bg-emerald-50 border-2 border-emerald-100 p-4 lg:p-8 rounded-lg text-emerald-950">
<p className="font-sans text-base font-semibold mb-6">
Welcome to LLM Markdown!
</p>
<p className="font-sans text-base mb-6">
This app is a demo for supporting rich-text responses from a{" "}
<Link
href="https://en.wikipedia.org/wiki/Large_language_model"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
Large Language Model
</Link>{" "}
(LLM). Once you provide your OpenAI API Key, you can start prompting and
receiving rich-text responses.
</p>
<p className="font-sans text-base mb-6">
Try something like:{" "}
<em className="italic font-semibold">
"Top 10 grossing movies of all time as a pie chart"
</em>
</p>
<p className="font-sans text-base font-semibold mt-10 mb-6">
How it works
</p>
<p className="font-sans text-base mb-6">
A system prompt is used to encourage the LLM to generate a response with
Markdown and Mermaid formatting.
</p>
<p className="font-sans text-base mb-6">
The response is rendered using{" "}
<Link
href="https://unifiedjs.com"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
unified
</Link>
,{" "}
<Link
href="https://mermaid.js.org"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
Mermaid
</Link>
, and{" "}
<Link
href="https://latex.js.org"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
LaTeX.js
</Link>{" "}
with{" "}
<Link
href="https://nextjs.org"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
Nextjs
</Link>
.
</p>
<p className="font-sans text-base mb-6">
For an example of the supported formatting, see{" "}
<Link href="/markdown" className={ANCHOR_CLASS_NAME}>
supported markdown
</Link>
.
</p>
<p className="font-sans text-base mb-6">
All the source code is available on{" "}
<Link
href="https://github.com/skovy/llm-markdown"
target="_blank"
className={ANCHOR_CLASS_NAME}
>
GitHub
</Link>
.
</p>
</div>
);
};
================================================
FILE: src/components/message-list.tsx
================================================
import Message from "./message";
import { Message as MessageType } from "ai/react";
interface Props {
messages: MessageType[];
}
export const MessageList = ({ messages }: Props) => {
return (
<ul className="grid auto-rows-min gap-4 max-w-2xl flex-1 mx-auto w-full">
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</ul>
);
};
================================================
FILE: src/components/message.tsx
================================================
import { Message } from "ai/react";
import { UserMessage } from "./user-message";
import { AssistantMessage } from "./assistant-message";
interface Props {
message: Message;
}
export default function Message({ message }: Props) {
switch (message.role) {
case "user":
return <UserMessage>{message.content}</UserMessage>;
case "assistant":
return <AssistantMessage>{message.content}</AssistantMessage>;
default:
throw new Error(`Unknown message role: ${message.role}`);
}
}
================================================
FILE: src/components/model-dialog.tsx
================================================
import { Dialog } from "./dialog";
export const SUPPORTED_MODELS = [
"gpt-3.5-turbo",
"gpt-3.5-turbo-16k",
"gpt-4",
] as const;
export type SupportedModels = (typeof SUPPORTED_MODELS)[number];
interface Props {
open: boolean;
setOpen(open: boolean): void;
model: SupportedModels;
setModel(mode: SupportedModels): void;
}
export const ModelDialog = ({ open, setOpen, model, setModel }: Props) => {
return (
<Dialog
open={open}
setOpen={setOpen}
title="OpenAI Model"
description={
<>
Choose the OpenAI model to use. Note, you must have access to the
model you select. If you are not sure, select{" "}
<code className="font-mono text-emerald-950">gpt-3.5-turbo</code>.
</>
}
>
<fieldset className="flex-grow-0">
<label
className="font-sans text-sm text-emerald-950 font-semibold mb-2 block"
htmlFor="model"
>
Model
</label>
<select
id="model"
name="model"
value={model}
onChange={(e) => setModel(e.currentTarget.value as SupportedModels)}
className="w-full border-2 border-slate-200 rounded-lg p-2 font-sans text-base outline-none ring-offset-0 focus:border-slate-400 focus-visible:ring-2 focus-visible:ring-offset-2 ring-emerald-600 transition-[box-shadow,border-color]"
>
{SUPPORTED_MODELS.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</fieldset>
</Dialog>
);
};
================================================
FILE: src/components/nav.tsx
================================================
import { GithubLogo } from "@phosphor-icons/react";
import MarkerCircle from "@phosphor-icons/react/dist/icons/MarkerCircle";
import Link from "next/link";
export const Nav = () => {
return (
<nav className="max-w-2xl w-full mx-auto py-2 lg:py-4 flex gap-2 items-center justify-between sticky top-0 bg-white z-10">
<a
href="/"
className="flex gap-2 items-center text-emerald-900 hover:text-emerald-950 group transition-colors"
>
<div className="p-2 rounded-full bg-emerald-50 group-hover:bg-emerald-100 transition-colors">
<MarkerCircle className="w-6 h-6" />
</div>
<span className="font-mono font-semibold">LLM Markdown</span>
</a>
<Link
href="https://github.com/skovy/llm-markdown"
target="_blank"
aria-label="Open GitHub repository"
className="p-2 rounded-full bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-800 transition-colors"
>
<GithubLogo className="w-6 h-6" />
</Link>
</nav>
);
};
================================================
FILE: src/components/token-dialog.tsx
================================================
import { ANCHOR_CLASS_NAME } from "@/hooks/use-markdown-processor";
import { useState } from "react";
import { Dialog } from "./dialog";
interface Props {
open: boolean;
setOpen(open: boolean): void;
setToken(key: string): void;
}
export const TokenDialog = ({ open, setOpen, setToken }: Props) => {
const [tokenInput, setTokenInput] = useState("");
return (
<Dialog
open={open}
setOpen={setOpen}
title="Set OpenAI API Key"
description={
<>
Since this is a demo app, you will need to provide your own OpenAI API
Key. This will be saved in your browser's local storage under the
name{" "}
<code className="font-mono text-emerald-900 font-semibold">
ai-token
</code>
. This will only be sent when you ask a question, and never persisted
outside of your browser. If you have not obtained your OpenAI API key,
you can do so by{" "}
<a
href="https://platform.openai.com/signup/"
className={ANCHOR_CLASS_NAME}
>
signing up
</a>{" "}
on the OpenAI website.
</>
}
>
<fieldset>
<label
className="font-sans text-sm text-emerald-950 font-semibold mb-2 block"
htmlFor="token"
>
OpenAI API Key
</label>
<input
id="token"
name="token"
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
className="w-full border-2 border-slate-200 rounded-lg p-2 font-sans text-base outline-none ring-offset-0 focus:border-slate-400 focus-visible:ring-2 focus-visible:ring-offset-2 ring-emerald-600 transition-[box-shadow,border-color] pr-10"
/>
</fieldset>
<div className="flex justify-end mt-8 gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="p-3 text-emerald-900 border-2 border-emerald-900 rounded-lg hover:bg-emerald-50 transition-colors"
>
<div className="font-sans text-base font-medium">Cancel</div>
</button>
<button
type="button"
onClick={() => {
setToken(tokenInput);
setOpen(false);
}}
disabled={!tokenInput}
className="p-3 text-emerald-50 border-2 border-emerald-900 bg-emerald-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed hover:bg-emerald-800 transition-colors"
>
<div className="font-sans text-base font-medium">Save</div>
</button>
</div>
</Dialog>
);
};
================================================
FILE: src/components/user-message.tsx
================================================
interface Props {
children: string;
}
export const UserMessage = ({ children }: Props) => {
return (
<li className="flex flex-col flex-1 min-w-0 gap-1 mr-6 selection:bg-indigo-300 selection:text-indigo-900">
<p className="font-sans text-xs font-medium text-indigo-700">You:</p>
<p className="p-2 lg:p-6 border-2 border-indigo-200 rounded-lg bg-indigo-50 font-sans text-sm text-indigo-900 min-w-0">
{children}
</p>
</li>
);
};
================================================
FILE: src/hooks/use-local-storage.ts
================================================
import { useEffect, useState } from "react";
export const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue);
useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);
const setValue = (value: T) => {
// Save state
setStoredValue(value);
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
};
================================================
FILE: src/hooks/use-markdown-processor.tsx
================================================
import { Dialog } from "@/components/dialog";
import { CircleNotch, MathOperations } from "@phosphor-icons/react";
import CheckFat from "@phosphor-icons/react/dist/icons/CheckFat";
import Copy from "@phosphor-icons/react/dist/icons/Copy";
import FlowArrow from "@phosphor-icons/react/dist/icons/FlowArrow";
import { Root } from "hast";
import "highlight.js/styles/base16/green-screen.css";
import mermaid from "mermaid";
import Link from "next/link";
import {
Children,
Fragment,
createElement,
isValidElement,
useEffect,
useMemo,
useState,
useRef,
} from "react";
import flattenChildren from "react-keyed-flatten-children";
import rehypeHighlight from "rehype-highlight";
import rehypeReact from "rehype-react";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { Plugin, unified } from "unified";
import { visit } from "unist-util-visit";
// @ts-expect-error
import { HtmlGenerator, parse } from "latex.js";
// import "node_modules/latex.js/dist/css/base.css"
import "node_modules/latex.js/dist/css/katex.css";
export const ANCHOR_CLASS_NAME =
"font-semibold underline text-emerald-700 underline-offset-[2px] decoration-1 hover:text-emerald-800 transition-colors";
// Mixing arbitrary Markdown + Capsize leads to lots of challenges
// with paragraphs and list items. This replaces paragraphs inside
// list items into divs to avoid nesting Capsize.
const rehypeListItemParagraphToDiv: Plugin<[], Root> = () => {
return (tree) => {
visit(tree, "element", (element) => {
if (element.tagName === "li") {
element.children = element.children.map((child) => {
if (child.type === "element" && child.tagName === "p") {
child.tagName = "div";
}
return child;
});
}
});
return tree;
};
};
export const useMarkdownProcessor = (content: string) => {
useEffect(() => {
mermaid.initialize({ startOnLoad: false, theme: "forest" });
}, []);
return useMemo(() => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeHighlight, { ignoreMissing: true })
.use(rehypeListItemParagraphToDiv)
.use(rehypeReact, {
createElement,
Fragment,
components: {
a: ({ href, children }: JSX.IntrinsicElements["a"]) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className={ANCHOR_CLASS_NAME}
>
{children}
</a>
),
h1: ({ children, id }: JSX.IntrinsicElements["h1"]) => (
<h1
className="font-sans font-semibold text-2xl text-emerald-950 mb-6 mt-6"
id={id}
>
{children}
</h1>
),
h2: ({ children, id }: JSX.IntrinsicElements["h2"]) => (
<h2
className="font-sans font-medium text-2xl text-emerald-950 mb-6 mt-6"
id={id}
>
{children}
</h2>
),
h3: ({ children, id }: JSX.IntrinsicElements["h3"]) => (
<h3
className="font-sans font-semibold text-xl text-emerald-950 mb-6 mt-2"
id={id}
>
{children}
</h3>
),
h4: ({ children, id }: JSX.IntrinsicElements["h4"]) => (
<h4
className="font-sans font-medium text-xl text-emerald-950 my-6"
id={id}
>
{children}
</h4>
),
h5: ({ children, id }: JSX.IntrinsicElements["h5"]) => (
<h5
className="font-sans font-semibold text-lg text-emerald-950 my-6"
id={id}
>
{children}
</h5>
),
h6: ({ children, id }: JSX.IntrinsicElements["h6"]) => (
<h6
className="font-sans font-medium text-lg text-emerald-950 my-6"
id={id}
>
{children}
</h6>
),
p: (props: JSX.IntrinsicElements["p"]) => {
return (
<p className="font-sans text-sm text-emerald-900 mb-6">
{props.children}
</p>
);
},
strong: ({ children }: JSX.IntrinsicElements["strong"]) => (
<strong className="text-emerald-950 font-semibold">
{children}
</strong>
),
em: ({ children }: JSX.IntrinsicElements["em"]) => (
<em>{children}</em>
),
code: CodeBlock,
pre: ({ children }: JSX.IntrinsicElements["pre"]) => {
return (
<div className="relative mb-6">
<pre className="p-4 rounded-lg border-2 border-emerald-200 bg-emerald-100 [&>code.hljs]:p-0 [&>code.hljs]:bg-transparent font-code text-sm overflow-x-auto flex items-start">
{children}
</pre>
</div>
);
},
ul: ({ children }: JSX.IntrinsicElements["ul"]) => (
<ul className="flex flex-col gap-3 text-emerald-900 my-6 pl-3 [&_ol]:my-3 [&_ul]:my-3">
{Children.map(
flattenChildren(children).filter(isValidElement),
(child, index) => (
<li key={index} className="flex gap-2 items-start">
<div className="w-1 h-1 rounded-full bg-current block shrink-0 mt-1" />
{child}
</li>
)
)}
</ul>
),
ol: ({ children }: JSX.IntrinsicElements["ol"]) => (
<ol className="flex flex-col gap-3 text-emerald-900 my-6 pl-3 [&_ol]:my-3 [&_ul]:my-3">
{Children.map(
flattenChildren(children).filter(isValidElement),
(child, index) => (
<li key={index} className="flex gap-2 items-start">
<div
className="font-sans text-sm text-emerald-900 font-semibold shrink-0 min-w-[1.4ch]"
aria-hidden
>
{index + 1}.
</div>
{child}
</li>
)
)}
</ol>
),
li: ({ children }: JSX.IntrinsicElements["li"]) => (
<div className="font-sans text-sm">{children}</div>
),
table: ({ children }: JSX.IntrinsicElements["table"]) => (
<div className="overflow-x-auto mb-6">
<table className="table-auto border-2 border-emerald-200">
{children}
</table>
</div>
),
thead: ({ children }: JSX.IntrinsicElements["thead"]) => (
<thead className="bg-emerald-100">{children}</thead>
),
th: ({ children }: JSX.IntrinsicElements["th"]) => (
<th className="border-2 border-emerald-200 p-2 font-sans text-sm font-semibold text-emerald-950">
{children}
</th>
),
td: ({ children }: JSX.IntrinsicElements["td"]) => (
<td className="border-2 border-emerald-200 p-2 font-sans text-sm text-emerald-900">
{children}
</td>
),
blockquote: ({ children }: JSX.IntrinsicElements["blockquote"]) => (
<blockquote className="border-l-4 border-emerald-200 pl-2 text-emerald-900 italic">
{children}
</blockquote>
),
},
})
.processSync(content).result;
}, [content]);
};
const CodeBlock = ({ children, className }: JSX.IntrinsicElements["code"]) => {
const [copied, setCopied] = useState(false);
const [showMermaidPreview, setShowMermaidPreview] = useState(false);
const [showLatexPreview, setShowLatexPreview] = useState(false);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (copied) {
const interval = setTimeout(() => setCopied(false), 1000);
return () => clearTimeout(interval);
}
}, [copied]);
// Highlight.js adds a `className` so this is a hack to detect if the code block
// is a language block wrapped in a `pre` tag.
if (className) {
const isMermaid = className.includes("language-mermaid");
const isLatex = className.includes("language-latex");
return (
<>
<code ref={ref} className={`${className} flex-grow flex-shrink my-auto`}>
{children}
</code>
<div className="flex flex-col gap-1 flex-grow-0 flex-shrink-0">
<button
type="button"
className="rounded-md p-1 text-emerald-900 hover:bg-emerald-200 border-2 border-emerald-200 transition-colors"
aria-label="copy code to clipboard"
title="Copy code to clipboard"
onClick={() => {
if (ref.current) {
navigator.clipboard.writeText(ref.current.innerText ?? "");
setCopied(true);
}
}}
>
{copied ? (
<CheckFat className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
{isMermaid ? (
<>
<button
type="button"
className="rounded-md p-1 text-emerald-900 hover:bg-emerald-200 border-2 border-emerald-200 transition-colors"
aria-label="Open Mermaid preview"
title="Open Mermaid preview"
onClick={() => {
setShowMermaidPreview(true);
}}
>
<FlowArrow className="w-4 h-4" />
</button>
<Dialog
open={showMermaidPreview}
setOpen={setShowMermaidPreview}
title="Mermaid diagram preview"
size="3xl"
>
<Mermaid content={children?.toString() ?? ""} />
</Dialog>
</>
) : null}
{isLatex ? (
<>
<button
type="button"
className="rounded-md p-1 text-emerald-900 hover:bg-emerald-200 border-2 border-emerald-200 transition-colors"
aria-label="Open Latex preview"
title="Open Latex preview"
onClick={() => {
setShowLatexPreview(true);
}}
>
<MathOperations className="w-4 h-4" />
</button>
<Dialog
open={showLatexPreview}
setOpen={setShowLatexPreview}
title="Latex diagram preview"
size="3xl"
>
<Latex content={children?.toString() ?? ""} />
</Dialog>
</>
) : null}
</div>
</>
);
}
return (
<code className="inline-block font-code bg-emerald-100 text-emerald-950 p-0.5 -my-0.5 rounded">
{children}
</code>
);
};
const Latex = ({ content }: { content: string }) => {
const [diagram, setDiagram] = useState<string | boolean>(true);
useEffect(() => {
try {
const generator = new HtmlGenerator({ hyphenate: false });
const fragment = parse(content, { generator: generator }).domFragment();
setDiagram(fragment.firstElementChild.outerHTML);
} catch (error) {
console.error(error);
setDiagram(false);
}
}, [content]);
if (diagram === true) {
return (
<div className="flex gap-2 items-center">
<CircleNotch className="animate-spin w-4 h-4 text-emerald-900" />
<p className="font-sans text-sm text-slate-700">Rendering diagram...</p>
</div>
);
} else if (diagram === false) {
return (
<p className="font-sans text-sm text-slate-700">
Unable to render this diagram.
</p>
);
} else {
return <div dangerouslySetInnerHTML={{ __html: diagram ?? "" }} />;
}
};
const Mermaid = ({ content }: { content: string }) => {
const [diagram, setDiagram] = useState<string | boolean>(true);
useEffect(() => {
const render = async () => {
// Generate a random ID for mermaid to use.
const id = `mermaid-svg-${Math.round(Math.random() * 10000000)}`;
// Confirm the diagram is valid before rendering.
if (await mermaid.parse(content, { suppressErrors: true })) {
const { svg } = await mermaid.render(id, content);
setDiagram(svg);
} else {
setDiagram(false);
}
};
render();
}, [content]);
if (diagram === true) {
return (
<div className="flex gap-2 items-center">
<CircleNotch className="animate-spin w-4 h-4 text-emerald-900" />
<p className="font-sans text-sm text-slate-700">Rendering diagram...</p>
</div>
);
} else if (diagram === false) {
return (
<p className="font-sans text-sm text-slate-700">
Unable to render this diagram. Try copying it into the{" "}
<Link
href="https://mermaid.live/edit"
className={ANCHOR_CLASS_NAME}
target="_blank"
>
Mermaid Live Editor
</Link>
.
</p>
);
} else {
return <div dangerouslySetInnerHTML={{ __html: diagram ?? "" }} />;
}
};
export const MARKDOWN_TEST_MESSAGE = `
# Heading level 1
This is the first paragraph.
This is the second paragraph.
This is the third paragraph.
## Heading level 2
This is an [anchor](https://github.com).
### Heading level 3
This is **bold** and _italics_.
#### Heading level 4
This is \`inline\` code.
This is a code block:
\`\`\`tsx
const Message = () => {
return <div>hi</div>;
};
\`\`\`
##### Heading level 5
This is an unordered list:
- One
- Two
- Three, and **bold**
This is an ordered list:
1. One
1. Two
1. Three
This is a complex list:
1. **Bold**: One
- One
- Two
- Three
2. **Bold**: Three
- One
- Two
- Three
3. **Bold**: Four
- One
- Two
- Three
###### Heading level 6
> This is a blockquote.
This is a table:
| Vegetable | Description |
|-----------|-------------|
| Carrot | A crunchy, orange root vegetable that is rich in vitamins and minerals. It is commonly used in soups, salads, and as a snack. |
| Broccoli | A green vegetable with tightly packed florets that is high in fiber, vitamins, and antioxidants. It can be steamed, boiled, stir-fried, or roasted. |
| Spinach | A leafy green vegetable that is dense in nutrients like iron, calcium, and vitamins. It can be eaten raw in salads or cooked in various dishes. |
| Bell Pepper | A colorful, sweet vegetable available in different colors such as red, yellow, and green. It is often used in stir-fries, salads, or stuffed recipes. |
| Tomato | A juicy fruit often used as a vegetable in culinary preparations. It comes in various shapes, sizes, and colors and is used in salads, sauces, and sandwiches. |
| Cucumber | A cool and refreshing vegetable with a high water content. It is commonly used in salads, sandwiches, or as a crunchy snack. |
| Zucchini | A summer squash with a mild flavor and tender texture. It can be sautéed, grilled, roasted, or used in baking recipes. |
| Cauliflower | A versatile vegetable that can be roasted, steamed, mashed, or used to make gluten-free alternatives like cauliflower rice or pizza crust. |
| Green Beans | Long, slender pods that are low in calories and rich in vitamins. They can be steamed, stir-fried, or used in casseroles and salads. |
| Potato | A starchy vegetable available in various varieties. It can be boiled, baked, mashed, or used in soups, fries, and many other dishes. |
This is a mermaid diagram:
\`\`\`mermaid
gitGraph
commit
commit
branch develop
checkout develop
commit
commit
checkout main
merge develop
commit
commit
\`\`\`
\`\`\`latex
\\[F(x) = \\int_{a}^{b} f(x) \\, dx\\]
\`\`\`
`;
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/hooks/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
fontFamily: {
sans: ["var(--font-sans)", "sans-serif"],
mono: ["var(--font-mono)", "monospace"],
},
capsize: {
fontMetrics: {
sans: require("@capsizecss/metrics/inter"),
monospace: require("@capsizecss/metrics/jetBrainsMono"),
},
},
},
plugins: [require("@themosaad/tailwindcss-capsize")],
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
gitextract_7slcflxo/ ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── next.config.js ├── package.json ├── postcss.config.js ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── chat/ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── markdown/ │ │ │ └── page.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── assistant-message.tsx │ │ ├── dialog.tsx │ │ ├── empty-message.tsx │ │ ├── message-list.tsx │ │ ├── message.tsx │ │ ├── model-dialog.tsx │ │ ├── nav.tsx │ │ ├── token-dialog.tsx │ │ └── user-message.tsx │ └── hooks/ │ ├── use-local-storage.ts │ └── use-markdown-processor.tsx ├── tailwind.config.js └── tsconfig.json
SYMBOL INDEX (17 symbols across 12 files)
FILE: src/app/api/chat/route.ts
constant SYSTEM_PROMPT (line 11) | const SYSTEM_PROMPT = `You are a general answering assistant that can co...
function POST (line 28) | async function POST(req: Request) {
FILE: src/app/layout.tsx
function RootLayout (line 21) | function RootLayout({
FILE: src/app/markdown/page.tsx
function Chat (line 9) | function Chat() {
FILE: src/app/page.tsx
function Chat (line 27) | function Chat() {
FILE: src/components/assistant-message.tsx
type Props (line 3) | interface Props {
FILE: src/components/dialog.tsx
type Props (line 5) | interface Props {
FILE: src/components/message-list.tsx
type Props (line 4) | interface Props {
FILE: src/components/message.tsx
type Props (line 5) | interface Props {
function Message (line 9) | function Message({ message }: Props) {
FILE: src/components/model-dialog.tsx
constant SUPPORTED_MODELS (line 3) | const SUPPORTED_MODELS = [
type SupportedModels (line 9) | type SupportedModels = (typeof SUPPORTED_MODELS)[number];
type Props (line 11) | interface Props {
FILE: src/components/token-dialog.tsx
type Props (line 5) | interface Props {
FILE: src/components/user-message.tsx
type Props (line 1) | interface Props {
FILE: src/hooks/use-markdown-processor.tsx
constant ANCHOR_CLASS_NAME (line 32) | const ANCHOR_CLASS_NAME =
constant MARKDOWN_TEST_MESSAGE (line 396) | const MARKDOWN_TEST_MESSAGE = `
Condensed preview — 25 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (45K chars).
[
{
"path": ".eslintrc.json",
"chars": 40,
"preview": "{\n \"extends\": \"next/core-web-vitals\"\n}\n"
},
{
"path": ".gitignore",
"chars": 368,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "LICENSE",
"chars": 1074,
"preview": "MIT License\n\nCopyright (c) 2023 Spencer Miskoviak\n\nPermission is hereby granted, free of charge, to any person obtaining"
},
{
"path": "README.md",
"chars": 966,
"preview": "# 📝 LLM Markdown\n\nA [Nextjs](https://nextjs.org) app demonstrating how to display rich-text responses from Large Languag"
},
{
"path": "next.config.js",
"chars": 92,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {}\n\nmodule.exports = nextConfig\n"
},
{
"path": "package.json",
"chars": 1192,
"preview": "{\n \"name\": \"nextjs-ai-markdown\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"bu"
},
{
"path": "postcss.config.js",
"chars": 82,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n}\n"
},
{
"path": "src/app/api/chat/route.ts",
"chars": 2220,
"preview": "import { OpenAIStream, StreamingTextResponse } from \"ai\";\nimport { NextResponse } from \"next/server\";\nimport {\n ChatCom"
},
{
"path": "src/app/globals.css",
"chars": 59,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n"
},
{
"path": "src/app/layout.tsx",
"chars": 822,
"preview": "import { Analytics } from \"@vercel/analytics/react\";\nimport classNames from \"classnames\";\nimport { Inter, JetBrains_Mono"
},
{
"path": "src/app/markdown/page.tsx",
"chars": 841,
"preview": "\"use client\";\n\nimport { Nav } from \"@/components/nav\";\nimport {\n MARKDOWN_TEST_MESSAGE,\n useMarkdownProcessor,\n} from "
},
{
"path": "src/app/page.tsx",
"chars": 4289,
"preview": "\"use client\";\n\nimport { EmptyMessage } from \"@/components/empty-message\";\nimport { MessageList } from \"@/components/mess"
},
{
"path": "src/components/assistant-message.tsx",
"chars": 630,
"preview": "import { useMarkdownProcessor } from \"@/hooks/use-markdown-processor\";\n\ninterface Props {\n children: string;\n}\n\nexport "
},
{
"path": "src/components/dialog.tsx",
"chars": 1699,
"preview": "import X from \"@phosphor-icons/react/dist/icons/X\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { "
},
{
"path": "src/components/empty-message.tsx",
"chars": 2721,
"preview": "import { ANCHOR_CLASS_NAME } from \"@/hooks/use-markdown-processor\";\nimport Link from \"next/link\";\n\nexport const EmptyMes"
},
{
"path": "src/components/message-list.tsx",
"chars": 376,
"preview": "import Message from \"./message\";\nimport { Message as MessageType } from \"ai/react\";\n\ninterface Props {\n messages: Messa"
},
{
"path": "src/components/message.tsx",
"chars": 510,
"preview": "import { Message } from \"ai/react\";\nimport { UserMessage } from \"./user-message\";\nimport { AssistantMessage } from \"./as"
},
{
"path": "src/components/model-dialog.tsx",
"chars": 1609,
"preview": "import { Dialog } from \"./dialog\";\n\nexport const SUPPORTED_MODELS = [\n \"gpt-3.5-turbo\",\n \"gpt-3.5-turbo-16k\",\n \"gpt-4"
},
{
"path": "src/components/nav.tsx",
"chars": 1049,
"preview": "import { GithubLogo } from \"@phosphor-icons/react\";\nimport MarkerCircle from \"@phosphor-icons/react/dist/icons/MarkerCir"
},
{
"path": "src/components/token-dialog.tsx",
"chars": 2684,
"preview": "import { ANCHOR_CLASS_NAME } from \"@/hooks/use-markdown-processor\";\nimport { useState } from \"react\";\nimport { Dialog } "
},
{
"path": "src/components/user-message.tsx",
"chars": 467,
"preview": "interface Props {\n children: string;\n}\n\nexport const UserMessage = ({ children }: Props) => {\n return (\n <li classN"
},
{
"path": "src/hooks/use-local-storage.ts",
"chars": 605,
"preview": "import { useEffect, useState } from \"react\";\n\nexport const useLocalStorage = <T>(\n key: string,\n initialValue: T\n): [T"
},
{
"path": "src/hooks/use-markdown-processor.tsx",
"chars": 16118,
"preview": "import { Dialog } from \"@/components/dialog\";\nimport { CircleNotch, MathOperations } from \"@phosphor-icons/react\";\nimpor"
},
{
"path": "tailwind.config.js",
"chars": 624,
"preview": "/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n content: [\n \"./src/pages/**/*.{js,ts,jsx,tsx,mdx}\",\n"
},
{
"path": "tsconfig.json",
"chars": 642,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
}
]
About this extraction
This page contains the full source code of the skovy/llm-markdown GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 25 files (40.8 KB), approximately 11.2k tokens, and a symbol index with 17 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.