Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to copy code snippets #44

Merged
merged 5 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/lib/components/Button.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@
@apply underline underline-offset-4 hover:text-neutral-500;
}

&--icon {
@apply opacity-25 hover:opacity-100;
&--icon :global(svg) {
@apply opacity-25 hover:opacity-50 active:opacity-100;
Comment on lines +69 to +70
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applies a darker color when clicking on the icon.

}

&--size {
Expand Down
2 changes: 1 addition & 1 deletion src/routes/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
</div>
{/if}

{#each session.messages as message}
{#each session.messages as message, i (session.id + i)}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to set a unique key for every instance of <Article> so it get's mounted/dismounted every time the session changes.

<Article {message} />
{/each}

Expand Down
57 changes: 41 additions & 16 deletions src/routes/[id]/Article.svelte
Original file line number Diff line number Diff line change
@@ -1,36 +1,51 @@
<script lang="ts">
import { onMount } from 'svelte';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { Files } from 'lucide-svelte';

import { type Message } from '$lib/sessions';
import Separator from '$lib/components/Separator.svelte';
import Button from '$lib/components/Button.svelte';
import CopyButton from './CopyButton.svelte';

export let message: Message;
let articleElement: HTMLElement;

const CODE_SNIPPET_ID = 'code-snippet';
const isUserRole = message.role === 'user';

function renderCodeSnippet(code: string) {
return `<pre id="${CODE_SNIPPET_ID}"><code class="hljs">${code}</code></pre>`;
}

const md: MarkdownIt = new MarkdownIt({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre><code class="hljs">${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
} catch (__) {}
return renderCodeSnippet(
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
);
} catch (_) {}
}

return `<pre><code class="hljs">${md.utils.escapeHtml(str)}</code></pre>
`;
return renderCodeSnippet(md.utils.escapeHtml(str));
}
});

export let message: Message;
const isUserRole = message.role === 'user';
onMount(() => {
const preElements = articleElement.querySelectorAll(`pre#${CODE_SNIPPET_ID}`);

function copyMessage() {
navigator.clipboard.writeText(message.content);
}
preElements.forEach((preElement) => {
const codeElement = preElement.querySelector('code');
if (codeElement)
new CopyButton({ target: preElement, props: { content: codeElement.innerText } });
});
});
</script>

<article
class="mx-auto flex w-full max-w-[70ch] flex-col gap-y-3 mb-3 last:mb-0"
class="mx-auto mb-3 flex w-full max-w-[70ch] flex-col gap-y-3 last:mb-0"
bind:this={articleElement}
>
<nav class="grid grid-cols-[max-content_auto_max-content] items-center">
<p
Expand All @@ -40,9 +55,7 @@
{isUserRole ? 'You' : message.role}
</p>
<Separator />
<Button title="Copy message" variant="icon" size="icon" on:click={copyMessage}>
<Files class="h-4 w-4" />
</Button>
<CopyButton content={message.content} />
</nav>

<div id="markdown" class="text-md mx-auto w-full overflow-x-auto px-[4ch]">
Expand All @@ -66,8 +79,20 @@
@apply mb-4;
}

:global(pre) {
@apply relative;
}

:global(pre:hover > button) {
@apply opacity-100;
}

:global(pre > button) {
@apply opacity-0 absolute top-1 right-0 bg-white;
}

:global(pre > code) {
@apply rounded-md p-4 text-sm;
@apply rounded-md p-4 text-sm pr-12;
}

:global(li > code),
Expand Down
14 changes: 14 additions & 0 deletions src/routes/[id]/CopyButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import Button from "$lib/components/Button.svelte";
import { Files } from "lucide-svelte";

export let content: string;

function copyContent() {
navigator.clipboard.writeText(content);
}
</script>

<Button title="Copy" variant="icon" size="icon" on:click={copyContent}>
<Files class="h-4 w-4" />
</Button>
18 changes: 16 additions & 2 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { MOCK_SESSION_1_RESPONSE_1, MOCK_SESSION_1_RESPONSE_2, MOCK_SESSION_2_RESPONSE_1, chooseModelFromSettings, mockCompletionResponse, mockTagsResponse } from './utils';
import { MOCK_SESSION_1_RESPONSE_1, MOCK_SESSION_1_RESPONSE_2, MOCK_SESSION_1_RESPONSE_3, MOCK_SESSION_2_RESPONSE_1, chooseModelFromSettings, mockCompletionResponse, mockTagsResponse } from './utils';

test.beforeEach(async ({ page }) => {
await mockTagsResponse(page);
Expand Down Expand Up @@ -188,21 +188,35 @@ test('all sessions can be deleted', async ({ page }) => {
expect(await page.evaluate(() => window.localStorage.getItem('hollama-sessions'))).toBe('null');
});

test('copies the raw text of a message to clipboard', async ({ page }) => {
test('can copy the raw text of a message or code snippets to clipboard', async ({ page }) => {
await page.goto('/');
await chooseModelFromSettings(page, 'gemma:7b');
await mockCompletionResponse(page, MOCK_SESSION_1_RESPONSE_1);
await page.getByTestId('new-session').click();
await page.getByLabel('Prompt').fill('Who would win in a fight between Emma Watson and Jessica Alba?');
await page.getByText('Send').click();
await expect(page.getByText("I am unable to provide subjective or speculative information, including fight outcomes between individuals.")).toBeVisible();
await expect(page.getByTitle('Copy')).toHaveCount(2);
expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("");

await page.getByTitle('Copy').first().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("Who would win in a fight between Emma Watson and Jessica Alba?");

await page.getByTitle('Copy').last().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("I am unable to provide subjective or speculative information, including fight outcomes between individuals.");
await expect(page.locator("pre")).not.toBeVisible();
await expect(page.locator("code")).not.toBeVisible();

await mockCompletionResponse(page, MOCK_SESSION_1_RESPONSE_3);
await page.getByLabel('Prompt').fill("Write a Python function to calculate the odds of the winner in a fight between Emma Watson and Jessica Alba");
await page.getByText('Send').click();
await expect(page.locator("pre")).toBeVisible();
await expect(page.locator("code")).toBeVisible();
await expect(page.getByTitle('Copy')).toHaveCount(5);

await page.locator("pre").hover();
await page.getByTitle('Copy').last().click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toEqual("def calculate_odds(emma_age, emma_height, emma_weight, emma_experience, jessica_age, jessica_height, jessica_weight, jessica_experience):\n emma_stats = {'age': emma_age, 'height': emma_height, 'weight': emma_weight, 'experience': emma_experience}\n jessica_stats = {'age': jessica_age, 'height': jessica_height, 'weight': jessica_weight, 'experience': jessica_experience}\n \n # Calculate the differences between their stats\n age_difference = abs(emma_stats['age'] - jessica_stats['age'])\n height_difference = abs(emma_stats['height'] - jessica_stats['height'])\n weight_difference = abs(emma_stats['weight'] - jessica_stats['weight'])\n \n # Return the differences as a tuple\n return (age_difference, height_difference, weight_difference)\n");
});

test('can start a new session, choose a model and stop a completion in progress', async ({ page }) => {
Expand Down
139 changes: 17 additions & 122 deletions tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,47 +41,7 @@ export const MOCK_SESSION_1_RESPONSE_1: OllamaCompletionResponse = {
created_at: "2024-04-10T22:54:40.310905Z",
response: "I am unable to provide subjective or speculative information, including fight outcomes between individuals.",
done: true,
context: [
106,
1645,
108,
6571,
1134,
3709,
575,
476,
5900,
1865,
29349,
29678,
578,
36171,
68187,
235336,
107,
108,
106,
2516,
108,
235285,
1144,
14321,
577,
3658,
45927,
689,
85979,
2113,
235269,
3359,
5900,
18701,
1865,
9278,
235265,
107,
108
],
context: [123, 4567, 890],
total_duration: 564546083,
load_duration: 419166,
prompt_eval_count: 18,
Expand All @@ -95,86 +55,21 @@ export const MOCK_SESSION_1_RESPONSE_2: OllamaCompletionResponse = {
created_at: "2024-04-10T23:08:33.419483Z",
response: "No problem! If you have any other questions or would like to discuss something else, feel free to ask.",
done: true,
context: [
106,
1645,
108,
6571,
1134,
3709,
575,
476,
5900,
1865,
29349,
29678,
578,
36171,
68187,
235336,
107,
108,
106,
2516,
108,
235285,
1144,
14321,
577,
3658,
45927,
689,
85979,
2113,
235269,
3359,
5900,
18701,
1865,
9278,
235265,
107,
108,
106,
1645,
108,
235285,
3508,
235269,
665,
235303,
235256,
12763,
107,
108,
106,
2516,
108,
1294,
3210,
235341,
1927,
692,
791,
1089,
1156,
3920,
689,
1134,
1154,
577,
9742,
2775,
1354,
235269,
2375,
2223,
577,
5291,
235265,
107,
108
],
context: [123, 4567, 890],
total_duration: 1574338000,
load_duration: 1044484792,
prompt_eval_count: 55,
prompt_eval_duration: 130165000,
eval_count: 23,
eval_duration: 399362000,
};

export const MOCK_SESSION_1_RESPONSE_3: OllamaCompletionResponse = {
model: "gemma:7b",
created_at: "2024-04-10T23:08:33.419483Z",
response: "Here's a basic function that takes the age, height, weight, and fighting experience of both individuals as input and returns the difference between their ages, heights, and weights.\n```python\ndef calculate_odds(emma_age, emma_height, emma_weight, emma_experience, jessica_age, jessica_height, jessica_weight, jessica_experience):\n emma_stats = {'age': emma_age, 'height': emma_height, 'weight': emma_weight, 'experience': emma_experience}\n jessica_stats = {'age': jessica_age, 'height': jessica_height, 'weight': jessica_weight, 'experience': jessica_experience}\n \n # Calculate the differences between their stats\n age_difference = abs(emma_stats['age'] - jessica_stats['age'])\n height_difference = abs(emma_stats['height'] - jessica_stats['height'])\n weight_difference = abs(emma_stats['weight'] - jessica_stats['weight'])\n \n # Return the differences as a tuple\n return (age_difference, height_difference, weight_difference)\n```\nYou can use this function to compare Emma Watson and Jessica Alba by providing their respective statistics as inputs.",
done: true,
context: [123, 4567, 890],
total_duration: 1574338000,
load_duration: 1044484792,
prompt_eval_count: 55,
Expand All @@ -188,7 +83,7 @@ export const MOCK_SESSION_2_RESPONSE_1: OllamaCompletionResponse = {
created_at: "2024-04-11T12:50:18.826017Z",
response: 'The fox says various things, such as "ring-a-ding-ding," "bada bing-bing" and "higglety-pigglety pop.',
done: true,
context: [123,4567,890],
context: [123, 4567, 890],
total_duration: 8048965583,
load_duration: 5793693500,
prompt_eval_count: 22,
Expand Down