Skip to content

Commit

Permalink
Merge pull request #55 from zamm-dev/markdown-chat
Browse files Browse the repository at this point in the history
Add Markdown rendering for chat messages
  • Loading branch information
amosjyng authored Mar 2, 2024
2 parents cc2431d + eb9b93e commit 6c5f56e
Show file tree
Hide file tree
Showing 18 changed files with 224 additions and 51 deletions.
5 changes: 4 additions & 1 deletion src-svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@
"vitest": "^1.2.2"
},
"dependencies": {
"@fontsource/inconsolata": "^5.0.16",
"@fontsource/jetbrains-mono": "^5.0.11",
"@neodrag/svelte": "file:../forks/neodrag/packages/svelte",
"autosize": "^6.0.1",
"nanoid": "^3.3.6"
"nanoid": "^3.3.6",
"svelte-highlight": "^7.6.0",
"svelte-markdown": "^0.4.1"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 8 additions & 23 deletions src-svelte/src/routes/chat/Chat.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,32 +77,18 @@ const conversation: ChatMessage[] = [
{
role: "Human",
text:
"Okay, we need to fill this chat up to produce a scrollbar for " +
'Storybook. Say short phrases like "Yup" to fill this chat up quickly.',
},
{
role: "AI",
text: "Yup",
},
{
role: "Human",
text: "Nay",
},
{
role: "AI",
text: "Yay",
},
{
role: "Human",
text: "Say...",
"This is some Python code:\n\n" +
"```python\n" +
"def hello_world():\n" +
" print('Hello, world!')\n" +
"```\n\n" +
"Convert it to Rust",
},
{
role: "AI",
text:
"AIs don't actually talk like this, you know? " +
"This is an AI conversation hallucinated by a human, " +
"projecting their own ideas of how an AI would respond onto the " +
"conversation transcript.",
"Here's how the Python code you provided would look in Rust:\n\n" +
'```rust\nfn main() {\n println!("Hello, world!");\n}\n```',
},
];

Expand Down Expand Up @@ -144,7 +130,6 @@ BottomScrollIndicator.parameters = {
export const FullMessageWidth: StoryObj = Template.bind({}) as any;
FullMessageWidth.args = {
conversation,
showMostRecentMessage: false,
};
FullMessageWidth.parameters = {
viewport: {
Expand Down
17 changes: 10 additions & 7 deletions src-svelte/src/routes/chat/Chat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
let bottomShadow: HTMLDivElement;
onMount(() => {
resizeConversationView();
window.addEventListener("resize", resizeConversationView);
resizeConversationView(true);
const resizeCallback = () => resizeConversationView(false);
window.addEventListener("resize", resizeCallback);
let topScrollObserver = new IntersectionObserver(
intersectionCallback(topShadow),
Expand All @@ -39,7 +40,7 @@
bottomScrollObserver.observe(bottomIndicator);
return () => {
window.removeEventListener("resize", resizeConversationView);
window.removeEventListener("resize", resizeCallback);
topScrollObserver.disconnect();
bottomScrollObserver.disconnect();
};
Expand All @@ -62,19 +63,21 @@
}
}
function resizeConversationView() {
function resizeConversationView(initialMount = false) {
if (conversationView) {
conversationView.style.maxHeight = "8rem";
requestAnimationFrame(() => {
if (conversationView && conversationContainer) {
conversationView.style.maxHeight = `${conversationContainer.clientHeight}px`;
if (showMostRecentMessage) {
showChatBottom();
}
const conversationDimensions =
conversationView.getBoundingClientRect();
conversationWidthPx.set(conversationDimensions.width);
if (initialMount && showMostRecentMessage) {
showChatBottom();
// scroll to bottom again in case resized elements produce greater scroll
setTimeout(showChatBottom, 120);
}
}
});
}
Expand Down
68 changes: 68 additions & 0 deletions src-svelte/src/routes/chat/CodeRender.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script lang="ts">
import Highlight from "svelte-highlight";
import bash from "svelte-highlight/languages/bash";
import javascript from "svelte-highlight/languages/javascript";
import typescript from "svelte-highlight/languages/typescript";
import rust from "svelte-highlight/languages/rust";
import python from "svelte-highlight/languages/python";
import plaintext from "svelte-highlight/languages/plaintext";
import "svelte-highlight/styles/github.css";
export let text: string;
export let lang: string;
function getLanguageStr() {
if (lang) {
return lang.split(" ")[0];
}
return "plaintext";
}
function getLanguage() {
let languageStr = getLanguageStr();
switch (languageStr) {
case "sh":
case "bash":
return bash;
case "js":
case "javascript":
return javascript;
case "typescript":
return typescript;
case "rust":
return rust;
case "py":
case "python":
return python;
default:
return plaintext;
}
}
let language = getLanguage();
</script>

<div class="code">
<Highlight {language} code={text} />
</div>

<style>
.code {
overflow-x: auto;
box-sizing: border-box;
border-radius: var(--corner-roundness);
background-color: #ffffff88;
}
.code :global(code) {
padding: var(--internal-spacing) 1rem;
background-color: transparent;
font-family: "Inconsolata", monospace;
}
.code,
.code :global(pre),
.code :global(code) {
width: fit-content;
}
</style>
19 changes: 19 additions & 0 deletions src-svelte/src/routes/chat/Message.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,22 @@ AIMultiline.parameters = {
defaultViewport: "tablet",
},
};

export const Code: StoryObj = Template.bind({}) as any;
Code.args = {
message: {
role: "Human",
text:
"This is some Python code:\n\n" +
"```python\n" +
"def hello_world():\n" +
" print('Hello, world!')\n" +
"```\n\n" +
"What do you think?",
},
};
Code.parameters = {
viewport: {
defaultViewport: "tablet",
},
};
25 changes: 24 additions & 1 deletion src-svelte/src/routes/chat/Message.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
<script lang="ts">
import type { ChatMessage } from "$lib/bindings";
import MessageUI from "./MessageUI.svelte";
import CodeRender from "./CodeRender.svelte";
import SvelteMarkdown from "svelte-markdown";
export let message: ChatMessage;
</script>

<MessageUI role={message.role} {...$$restProps}>
{message.text}
<div class="markdown">
<SvelteMarkdown source={message.text} renderers={{ code: CodeRender }} />
</div>
</MessageUI>

<style>
.markdown {
width: fit-content;
}
.markdown :global(p) {
margin: var(--internal-spacing) 0;
}
.markdown :global(:first-child) {
margin-top: 0;
}
.markdown :global(:last-child) {
margin-bottom: 0;
}
</style>
72 changes: 54 additions & 18 deletions src-svelte/src/routes/chat/MessageUI.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,77 @@
let initialResizeTimeoutId: ReturnType<typeof setTimeout> | undefined;
let finalResizeTimeoutId: ReturnType<typeof setTimeout> | undefined;
const remPx = 18;
// arrow size, left padding, and right padding
const messagePaddingPx = (0.5 + 0.75 + 0.75) * remPx;
// chat window size at which the message bubble should be full width
const MIN_FULL_WIDTH_PX = 400;
const MAX_WIDTH_PX = 600;
function maxMessageWidth(chatWidthPx: number) {
if (chatWidthPx <= MIN_FULL_WIDTH_PX) {
return chatWidthPx;
const availableWidthPx = chatWidthPx - messagePaddingPx;
if (availableWidthPx <= MIN_FULL_WIDTH_PX) {
return availableWidthPx;
}
const fractionalWidth = Math.max(0.8 * chatWidthPx, MIN_FULL_WIDTH_PX);
const fractionalWidth = Math.max(0.8 * availableWidthPx, MIN_FULL_WIDTH_PX);
return Math.min(fractionalWidth, MAX_WIDTH_PX);
}
function resetChildren(textElement: HTMLDivElement) {
const pElements = textElement.querySelectorAll("p");
pElements.forEach((pElement) => {
pElement.style.width = "";
});
const codeElements = textElement.querySelectorAll<HTMLDivElement>(".code");
codeElements.forEach((codeElement) => {
codeElement.style.width = "";
});
}
function resizeChildren(textElement: HTMLDivElement, maxWidth: number) {
const pElements = textElement.querySelectorAll("p");
pElements.forEach((pElement) => {
const range = document.createRange();
range.selectNodeContents(pElement);
const textRect = range.getBoundingClientRect();
const actualTextWidth = textRect.width;
pElement.style.width = `${actualTextWidth}px`;
});
const codeElements = textElement.querySelectorAll<HTMLDivElement>(".code");
codeElements.forEach((codeElement) => {
let existingWidth = codeElement.getBoundingClientRect().width;
if (existingWidth > maxWidth) {
codeElement.style.width = `${maxWidth}px`;
}
});
}
function resizeBubble(chatWidthPx: number) {
if (chatWidthPx > 0 && textElement) {
try {
textElement.style.width = "";
const markdownElement =
textElement.querySelector<HTMLDivElement>(".markdown");
if (!markdownElement) {
return;
}
resetChildren(markdownElement);
const maxWidth = maxMessageWidth(chatWidthPx);
const currentWidth = textElement.getBoundingClientRect().width;
const newWidth = Math.min(currentWidth, maxWidth);
textElement.style.width = `${newWidth}px`;
const currentWidth = markdownElement.getBoundingClientRect().width;
const newWidth = Math.ceil(Math.min(currentWidth, maxWidth));
markdownElement.style.width = `${newWidth}px`;
if (finalResizeTimeoutId) {
clearTimeout(finalResizeTimeoutId);
}
finalResizeTimeoutId = setTimeout(() => {
if (textElement) {
const range = document.createRange();
range.selectNodeContents(textElement);
const textRect = range.getBoundingClientRect();
const actualTextWidth = textRect.width;
const finalWidth = Math.min(actualTextWidth, newWidth);
textElement.style.width = `${finalWidth}px`;
}
resizeChildren(markdownElement, maxWidth);
markdownElement.style.width = "";
}, 10);
} catch (err) {
console.warn("Cannot resize chat message bubble: ", err);
Expand Down Expand Up @@ -76,22 +111,23 @@
.message {
--message-color: gray;
--arrow-size: 0.5rem;
--internal-spacing: 0.75rem;
position: relative;
}
.message .text-container {
margin: 0.5rem var(--arrow-size);
border-radius: var(--corner-roundness);
width: fit-content;
padding: 0.75rem;
padding: var(--internal-spacing);
box-sizing: border-box;
background-color: var(--message-color);
white-space: pre-line;
text-align: left;
}
.text {
box-sizing: content-box;
width: fit-content;
max-width: 600px;
}
Expand Down
2 changes: 1 addition & 1 deletion src-svelte/src/routes/storybook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ const components: ComponentTestConfig[] = [
},
{
path: ["screens", "chat", "message"],
variants: ["human", "ai", "ai-multiline"],
variants: ["human", "ai", "ai-multiline", "code"],
},
{
path: ["screens", "chat", "conversation"],
Expand Down
1 change: 1 addition & 0 deletions src-svelte/src/routes/styles.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import "@fontsource/jetbrains-mono";
@import "@fontsource/inconsolata";

@font-face {
font-family: 'Nasalization';
Expand Down
Loading

0 comments on commit 6c5f56e

Please sign in to comment.