Files
vessel/frontend/src/lib/components/chat/MessageContent.svelte
vikingowl 9627bd3afc fix: hide tool result user messages and improve visibility
- Hide tool result messages (role: user) from chat display
  They're internal API messages, not actual user input
- Improve pattern matching to catch all tool result formats
- Clean up "Tool execution results:" and "Called tool:" text
- Detect and hide HTML garbage from failed fetch attempts
- Add fetch result type with proper styling
- Improve text visibility in fallback displays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 21:13:09 +01:00

407 lines
11 KiB
Svelte

<script lang="ts">
/**
* MessageContent - Renders markdown content with code highlighting
* Parses markdown, sanitizes HTML, extracts code blocks, and displays images
*/
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import CodeBlock from './CodeBlock.svelte';
import HtmlPreview from './HtmlPreview.svelte';
import ToolResultDisplay from './ToolResultDisplay.svelte';
import { base64ToDataUrl } from '$lib/ollama/image-processor';
interface Props {
content: string;
images?: string[];
}
const { content, images }: Props = $props();
// Pattern to find fenced code blocks
const CODE_BLOCK_PATTERN = /```(\w+)?\n([\s\S]*?)```/g;
// Pattern to detect tool results in various formats
const TOOL_RESULT_PATTERN = /Tool result:\s*(\{[\s\S]*?\}|\S[\s\S]*?)(?=\n\n|$)/;
const TOOL_ERROR_PATTERN = /Tool error:\s*(.+?)(?=\n\n|$)/;
// Pattern for "Called tool:" text (redundant with ToolCallDisplay)
const CALLED_TOOL_PATTERN = /Called tool:\s*\w+\([^)]*\)\s*\n*/g;
// Pattern for "Tool execution results:" header
const TOOL_EXEC_HEADER_PATTERN = /Tool execution results:\s*\n?/g;
// Languages that should show a preview
const PREVIEW_LANGUAGES = ['html', 'htm'];
interface ContentPart {
type: 'text' | 'code' | 'tool-result';
content: string;
language?: string;
showPreview?: boolean;
}
/** Check if a language should show preview */
function shouldShowPreview(language: string): boolean {
return PREVIEW_LANGUAGES.includes(language.toLowerCase());
}
/** Modal state for full-size image viewing */
let modalImage = $state<string | null>(null);
/**
* Clean redundant tool text (shown via ToolCallDisplay)
*/
function cleanToolText(text: string): string {
return text
.replace(CALLED_TOOL_PATTERN, '')
.replace(TOOL_EXEC_HEADER_PATTERN, '')
.replace(/^Based on these results.*$/gm, '')
.trim();
}
/**
* Check if text contains tool execution results
*/
function containsToolResult(text: string): boolean {
return text.includes('Tool execution results:') || text.includes('Tool result:') || text.includes('Tool error:');
}
/**
* Parse a text section for tool results
*/
function parseTextForToolResults(text: string): ContentPart[] {
const parts: ContentPart[] = [];
// Check for tool execution results pattern
const toolMatch = text.match(TOOL_RESULT_PATTERN);
if (toolMatch) {
const beforeTool = text.slice(0, toolMatch.index);
const toolContent = toolMatch[1];
const afterTool = text.slice((toolMatch.index || 0) + toolMatch[0].length);
if (beforeTool.trim()) {
parts.push({ type: 'text', content: beforeTool });
}
parts.push({ type: 'tool-result', content: toolContent });
if (afterTool.trim()) {
// Recursively parse remaining content
parts.push(...parseTextForToolResults(afterTool));
}
return parts;
}
// No tool result found, return as text
if (text.trim()) {
parts.push({ type: 'text', content: text });
}
return parts;
}
/**
* Parse content into parts (text, code blocks, and tool results)
*/
function parseContent(text: string): ContentPart[] {
const parts: ContentPart[] = [];
let lastIndex = 0;
// Reset regex state
CODE_BLOCK_PATTERN.lastIndex = 0;
// Find all code blocks
let match;
while ((match = CODE_BLOCK_PATTERN.exec(text)) !== null) {
// Add text before this code block (may contain tool results)
if (match.index > lastIndex) {
const textBefore = text.slice(lastIndex, match.index);
if (textBefore.trim()) {
if (containsToolResult(textBefore)) {
parts.push(...parseTextForToolResults(textBefore));
} else {
parts.push({ type: 'text', content: textBefore });
}
}
}
// Add the code block
const language = match[1] || 'text';
parts.push({
type: 'code',
content: match[2].trim(),
language,
showPreview: shouldShowPreview(language)
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after last code block
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex);
if (remaining.trim()) {
if (containsToolResult(remaining)) {
parts.push(...parseTextForToolResults(remaining));
} else {
parts.push({ type: 'text', content: remaining });
}
}
}
// If no code blocks found, check for tool results in entire content
if (parts.length === 0 && text.trim()) {
if (containsToolResult(text)) {
parts.push(...parseTextForToolResults(text));
} else {
parts.push({ type: 'text', content: text });
}
}
return parts;
}
/**
* Render markdown to sanitized HTML
*/
function renderMarkdown(text: string): string {
// Configure marked for safe rendering
const html = marked.parse(text, {
async: false,
gfm: true,
breaks: true
}) as string;
// Sanitize the HTML
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'b', 'i', 'u', 's', 'del',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'blockquote', 'hr',
'a', 'code', 'pre',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'img', 'span', 'div', 'sub', 'sup'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'target', 'rel']
});
}
/**
* Get the data URL for an image
*/
function getImageUrl(image: string): string {
return base64ToDataUrl(image);
}
/**
* Open the image modal
*/
function openImageModal(image: string): void {
modalImage = image;
}
/**
* Close the image modal
*/
function closeImageModal(): void {
modalImage = null;
}
/**
* Handle keyboard events on modal
*/
function handleModalKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
closeImageModal();
}
}
// Clean and parse content into parts
const cleanedContent = $derived(cleanToolText(content));
const contentParts = $derived(parseContent(cleanedContent));
</script>
<div class="message-content">
<!-- Render images if present -->
{#if images && images.length > 0}
<div class="mb-3 flex flex-wrap gap-2">
{#each images as image, index}
<button
type="button"
class="group relative block overflow-hidden rounded-lg border border-gray-200 bg-gray-100 transition-all hover:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:border-gray-700 dark:bg-gray-800"
onclick={() => openImageModal(image)}
>
<img
src={getImageUrl(image)}
alt={`Attached image ${index + 1}`}
class="max-h-48 max-w-full object-contain"
/>
<!-- Hover overlay with expand icon -->
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/20">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-6 w-6 text-white opacity-0 drop-shadow transition-opacity group-hover:opacity-100"
>
<path d="M13.28 7.78l3.22-3.22v2.69a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.69l-3.22 3.22a.75.75 0 001.06 1.06zM2 17.25v-4.5a.75.75 0 011.5 0v2.69l3.22-3.22a.75.75 0 011.06 1.06L4.56 16.5h2.69a.75.75 0 010 1.5h-4.5a.75.75 0 01-.75-.75zM12.22 13.28l3.22 3.22h-2.69a.75.75 0 000 1.5h4.5a.75.75 0 00.75-.75v-4.5a.75.75 0 00-1.5 0v2.69l-3.22-3.22a.75.75 0 10-1.06 1.06zM3.5 4.56l3.22 3.22a.75.75 0 001.06-1.06L4.56 3.5h2.69a.75.75 0 000-1.5h-4.5a.75.75 0 00-.75.75v4.5a.75.75 0 001.5 0V4.56z" />
</svg>
</div>
</button>
{/each}
</div>
{/if}
<!-- Render content parts -->
{#each contentParts as part, index (index)}
{#if part.type === 'code'}
<div class="my-3 space-y-3">
<CodeBlock
code={part.content}
language={part.language || 'text'}
/>
<!-- Show preview for HTML code blocks -->
{#if part.showPreview}
<HtmlPreview
html={part.content}
title="HTML Preview"
/>
{/if}
</div>
{:else if part.type === 'tool-result'}
<ToolResultDisplay content={part.content} />
{:else}
<div class="prose prose-sm prose-invert max-w-none">
{@html renderMarkdown(part.content)}
</div>
{/if}
{/each}
</div>
<!-- Full-size image modal -->
{#if modalImage}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Full size image"
onclick={closeImageModal}
onkeydown={handleModalKeydown}
>
<!-- Close button -->
<button
type="button"
onclick={closeImageModal}
class="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white transition-colors hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-white"
aria-label="Close preview"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-6 w-6"
>
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
<!-- Full-size image -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<img
src={getImageUrl(modalImage)}
alt="Full size view"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
/>
</div>
{/if}
<style>
/* Prose styling overrides for chat context (dark theme) */
.message-content :global(.prose) {
@apply leading-relaxed text-slate-200;
}
.message-content :global(.prose p) {
@apply my-1;
}
.message-content :global(.prose p:first-child) {
@apply mt-0;
}
.message-content :global(.prose p:last-child) {
@apply mb-0;
}
.message-content :global(.prose pre) {
@apply my-2 rounded-lg;
}
.message-content :global(.prose code:not(pre code)) {
@apply rounded bg-slate-700 px-1.5 py-0.5 text-sm text-emerald-400;
}
.message-content :global(.prose a) {
@apply text-blue-400 hover:text-blue-300 hover:underline;
}
.message-content :global(.prose ul),
.message-content :global(.prose ol) {
@apply my-2 pl-4;
}
.message-content :global(.prose li) {
@apply my-0.5;
}
.message-content :global(.prose blockquote) {
@apply border-l-4 border-slate-600 pl-4 italic text-slate-400;
}
.message-content :global(.prose table) {
@apply my-2 w-full border-collapse text-sm;
}
.message-content :global(.prose th),
.message-content :global(.prose td) {
@apply border border-slate-600 px-3 py-2;
}
.message-content :global(.prose th) {
@apply bg-slate-700 font-semibold text-slate-200;
}
.message-content :global(.prose td) {
@apply bg-slate-800/50 text-slate-300;
}
.message-content :global(.prose tr:hover td) {
@apply bg-slate-700/50;
}
.message-content :global(.prose strong) {
@apply text-slate-100;
}
.message-content :global(.prose em) {
@apply text-slate-300;
}
.message-content :global(.prose h1),
.message-content :global(.prose h2),
.message-content :global(.prose h3),
.message-content :global(.prose h4),
.message-content :global(.prose h5),
.message-content :global(.prose h6) {
@apply text-slate-100;
}
.message-content :global(.prose hr) {
@apply border-slate-600;
}
</style>