Skip to content
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
32 changes: 27 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@
"ajv": "^8.12.0",
"async-cache-dedupe": "^1.12.0",
"classnames": "^2.3.2",
"dompurify": "^3.4.12",
"highlight.js": "^11.11.1",
"marked": "^12.0.2",
"prom-client": "^15.1.3"
}
}
147 changes: 147 additions & 0 deletions src/app.postcss
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,150 @@
color: #a7a7ef !important;
opacity: 1 !important;
}

/* Rendered markdown in agent chat replies. Tailwind base strips list/heading
styles, so restore just what model output needs. */
.chat-md > :first-child {
margin-top: 0;
}
.chat-md p {
margin: 0.4rem 0;
}
.chat-md ul,
.chat-md ol {
margin: 0.4rem 0;
padding-left: 1.25rem;
}
.chat-md ul {
list-style: disc;
}
.chat-md ol {
list-style: decimal;
}
.chat-md li {
margin: 0.15rem 0;
}
.chat-md h1,
.chat-md h2,
.chat-md h3,
.chat-md h4 {
margin: 0.6rem 0 0.3rem;
font-weight: 600;
line-height: 1.25;
}
.chat-md h1 {
font-size: 1.1rem;
}
.chat-md h2 {
font-size: 1.05rem;
}
.chat-md h3,
.chat-md h4 {
font-size: 1rem;
}
.chat-md a {
color: #2563eb;
text-decoration: underline;
}
:is(.dark .chat-md) a {
color: #93c5fd;
}
.chat-md blockquote {
margin: 0.4rem 0;
padding-left: 0.75rem;
border-left: 3px solid #d1d5db;
color: #6b7280;
}
:is(.dark .chat-md) blockquote {
border-left-color: #4b5563;
color: #9ca3af;
}
/* Inline code */
.chat-md code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
background: var(--hl-bg);
padding: 0.1rem 0.3rem;
border-radius: 0.25rem;
}
/* Fenced code blocks: the <pre> owns the surface; the inner <code> is bare. */
.chat-md pre {
margin: 0.5rem 0;
padding: 0.65rem 0.75rem;
overflow-x: auto;
border-radius: 0.375rem;
background: var(--hl-bg);
}
.chat-md pre code {
padding: 0;
background: none;
font-size: 0.8rem;
line-height: 1.45;
}

/* highlight.js palette as CSS variables so a single set of scope rules serves
both themes (github light / github-dark). */
.chat-md {
--hl-bg: #f6f8fa;
--hl-fg: #24292e;
--hl-comment: #6a737d;
--hl-keyword: #d73a49;
--hl-string: #032f62;
--hl-number: #005cc5;
--hl-title: #6f42c1;
--hl-type: #e36209;
--hl-attr: #005cc5;
}
:is(.dark .chat-md) {
--hl-bg: #161b22;
--hl-fg: #c9d1d9;
--hl-comment: #8b949e;
--hl-keyword: #ff7b72;
--hl-string: #a5d6ff;
--hl-number: #79c0ff;
--hl-title: #d2a8ff;
--hl-type: #ffa657;
--hl-attr: #79c0ff;
}
.chat-md .hljs {
color: var(--hl-fg);
background: none;
}
.chat-md .hljs-comment,
.chat-md .hljs-quote {
color: var(--hl-comment);
font-style: italic;
}
.chat-md .hljs-keyword,
.chat-md .hljs-selector-tag,
.chat-md .hljs-literal,
.chat-md .hljs-doctag {
color: var(--hl-keyword);
}
.chat-md .hljs-string,
.chat-md .hljs-regexp,
.chat-md .hljs-addition {
color: var(--hl-string);
}
.chat-md .hljs-number,
.chat-md .hljs-built_in,
.chat-md .hljs-bullet {
color: var(--hl-number);
}
.chat-md .hljs-title,
.chat-md .hljs-section,
.chat-md .hljs-function .hljs-title {
color: var(--hl-title);
}
.chat-md .hljs-type,
.chat-md .hljs-class .hljs-title,
.chat-md .hljs-tag,
.chat-md .hljs-symbol {
color: var(--hl-type);
}
.chat-md .hljs-attr,
.chat-md .hljs-attribute,
.chat-md .hljs-variable,
.chat-md .hljs-name {
color: var(--hl-attr);
}
26 changes: 18 additions & 8 deletions src/lib/components/agentChat.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, afterUpdate } from 'svelte';
import type { File, ChatMessage } from '$lib/types';
import { renderMarkdown } from '$lib/markdown';
import { highlightWithin } from '$lib/highlight';

export let files: File[];
// Async ops capture these at start, so a late response commits to the example
Expand Down Expand Up @@ -49,6 +51,7 @@
if (scrollEl && !forcePin) autoFollow = atBottom();
}
afterUpdate(() => {
if (scrollEl) highlightWithin(scrollEl);
if (!scrollEl || !(forcePin || autoFollow)) return;
scrollEl.scrollTop = scrollEl.scrollHeight;
if (typeof requestAnimationFrame !== 'undefined') {
Expand Down Expand Up @@ -298,14 +301,21 @@
class="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-3"
>
{#each messages as m}
<div
class="text-sm {m.role === 'user'
? 'text-gray-900 dark:text-white'
: 'text-blue-700 dark:text-blue-300'}"
>
<span class="font-medium">{m.role === 'user' ? 'You' : 'Agent'}:</span>
<span class="whitespace-pre-wrap">{m.content}</span>
</div>
{#if m.role === 'user'}
<div class="text-sm text-gray-900 dark:text-white">
<span class="font-medium">You:</span>
<span class="whitespace-pre-wrap">{m.content}</span>
</div>
{:else}
<div class="text-sm">
<span class="font-medium text-blue-700 dark:text-blue-300">Agent:</span>
<!-- Model output is untrusted; renderMarkdown sanitizes (DOMPurify) before {@html}. -->
<div class="chat-md mt-1 text-gray-900 dark:text-gray-100">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMarkdown(m.content)}
</div>
</div>
{/if}
{/each}
{#if currentBusy}
<div class="text-sm italic text-gray-400">Thinking…</div>
Expand Down
31 changes: 31 additions & 0 deletions src/lib/components/agentChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,37 @@ describe('agentChat.svelte', () => {
);
});

it('renders assistant markdown as HTML and keeps user text plain', async () => {
vi.stubGlobal('fetch', mockFetch({ reply: 'x', files }));
const { getByText, container } = render(AgentChat, {
props: {
files,
onFilesChange: vi.fn(),
messages: [
{ role: 'user' as const, content: 'make it **bold**' },
{
role: 'assistant' as const,
content: 'sure: **done**\n```js\nconst z = 4;\n```'
}
],
chatCacheKey: 'chat:A'
}
});
await waitFor(() => expect(getByText('gpt-4o')).toBeInTheDocument());

// Assistant markdown becomes real HTML elements.
expect(container.querySelector('.chat-md strong')?.textContent).toBe('done');

// Fenced code renders as pre/code and gets syntax-highlighted by highlight.js.
const codeEl = container.querySelector('.chat-md pre code');
expect(codeEl?.textContent).toContain('const z = 4;');
await waitFor(() => expect(codeEl?.querySelector('.hljs-keyword')).not.toBeNull());

// User asterisks are shown literally, not rendered.
expect(getByText(/make it \*\*bold\*\*/)).toBeInTheDocument();
expect(container.querySelector('.chat-md strong')?.textContent).not.toBe('bold');
});

it('the header info summary appears on hover', async () => {
vi.stubGlobal('fetch', mockFetch({ reply: 'x', files }));
const { getByText, getByLabelText, queryByText, findByText } = render(AgentChat, {
Expand Down
28 changes: 28 additions & 0 deletions src/lib/highlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import json from 'highlight.js/lib/languages/json';
import xml from 'highlight.js/lib/languages/xml';
import css from 'highlight.js/lib/languages/css';
import bash from 'highlight.js/lib/languages/bash';
import python from 'highlight.js/lib/languages/python';
import markdown from 'highlight.js/lib/languages/markdown';

// Core build + a lean set of languages the demos actually use, so we don't
// pull in highlight.js's full ~200-language registry.
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('json', json);
hljs.registerLanguage('xml', xml); // also covers HTML
hljs.registerLanguage('css', css);
hljs.registerLanguage('bash', bash);
hljs.registerLanguage('python', python);
hljs.registerLanguage('markdown', markdown);

// Colorize any code blocks under `root` that haven't been highlighted yet.
// highlightElement stamps data-highlighted, so re-renders skip finished blocks.
export function highlightWithin(root: HTMLElement): void {
root.querySelectorAll<HTMLElement>('pre code').forEach((el) => {
if (!el.dataset.highlighted) hljs.highlightElement(el);
});
}
29 changes: 29 additions & 0 deletions src/lib/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { renderMarkdown } from './markdown';

describe('renderMarkdown', () => {
it('renders inline markdown to HTML', () => {
const html = renderMarkdown('a **bold** and `code` word');
expect(html).toContain('<strong>bold</strong>');
expect(html).toContain('<code>code</code>');
});

it('renders fenced code blocks as pre/code with a language class', () => {
const html = renderMarkdown('```js\nconst zoom = 4;\n```');
expect(html).toContain('<pre>');
expect(html).toMatch(/<code[^>]*class="[^"]*language-js/);
expect(html).toContain('const zoom = 4;');
});

it('strips dangerous markup from model output', () => {
const html = renderMarkdown('hi <img src=x onerror=alert(1)> <script>alert(2)</script>');
expect(html).not.toContain('onerror');
expect(html).not.toContain('<script>');
});

it('opens links in a new tab with noopener', () => {
const html = renderMarkdown('[docs](https://example.com)');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
});
Loading
Loading