From a3e18cb4dbdc706db55a98babe7cfa3f7e20d306 Mon Sep 17 00:00:00 2001 From: Josako Date: Tue, 30 Sep 2025 17:38:28 +0200 Subject: [PATCH] - Maximale hoogte voor AI message in ChatInput nu geldig voor zowel desktop als mobile devices. - Correctie marked component in SideBarExplanation.vue - AI messages ondersteunen nu markdown. Markdown rendering is als een centrale utility gedefinieerd. --- common/utils/execution_progress.py | 2 +- config/static-manifest/manifest.json | 4 +- .../assets/js/utils/markdownRenderer.js | 165 ++++++++++++++++++ .../assets/vue-components/ChatMessage.vue | 115 +++++++++--- .../vue-components/SideBarExplanation.vue | 33 ++-- 5 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 eveai_chat_client/static/assets/js/utils/markdownRenderer.js diff --git a/common/utils/execution_progress.py b/common/utils/execution_progress.py index 646f6ca..fdd1616 100644 --- a/common/utils/execution_progress.py +++ b/common/utils/execution_progress.py @@ -124,7 +124,7 @@ class ExecutionProgressTracker: yield f"data: {message['data'].decode('utf-8')}\n\n" # Check processing_type for completion - if update_data['processing_type'] in ['Task Complete', 'Task Error']: + if update_data['processing_type'] in ['Task Complete', 'Task Error', 'EveAI Specialist Complete']: break finally: try: diff --git a/config/static-manifest/manifest.json b/config/static-manifest/manifest.json index e401916..3b987e8 100644 --- a/config/static-manifest/manifest.json +++ b/config/static-manifest/manifest.json @@ -1,6 +1,6 @@ { - "dist/chat-client.js": "dist/chat-client.6bfbd765.js", - "dist/chat-client.css": "dist/chat-client.33f904ba.css", + "dist/chat-client.js": "dist/chat-client.24c00fcd.js", + "dist/chat-client.css": "dist/chat-client.7d8832b6.css", "dist/main.js": "dist/main.f3dde0f6.js", "dist/main.css": "dist/main.c40e57ad.css" } \ No newline at end of file diff --git a/eveai_chat_client/static/assets/js/utils/markdownRenderer.js b/eveai_chat_client/static/assets/js/utils/markdownRenderer.js new file mode 100644 index 0000000..a927910 --- /dev/null +++ b/eveai_chat_client/static/assets/js/utils/markdownRenderer.js @@ -0,0 +1,165 @@ +// Centralized Markdown rendering utility +// Safe defaults per requirements: no inline HTML, no images, no code blocks, allow tables, links open in new tab with rel attributes + +export function renderMarkdown(text, options = {}) { + const opts = { + allowInlineHTML: false, + enableTables: true, + enableBreaks: true, + enableImages: false, + enableCodeBlocks: false, + allowInlineCode: false, // inline code currently not allowed + linkTargetBlank: true, + sidebarAccent: false, + consoleWarnOnFallback: true, + ...options + }; + + const input = typeof text === 'string' ? text : String(text ?? ''); + if (!input) return ''; + + // Optional sidebar accent [[...]] -> ... BEFORE parsing + const preprocessed = opts.sidebarAccent + ? input.replace(/\[\[(.*?)\]\]/g, '$1') + : input; + + try { + const hasMarked = typeof window !== 'undefined' && window.marked; + + if (!hasMarked) { + if (opts.consoleWarnOnFallback) { + console.warn('renderMarkdown: Marked not available; falling back to plain text'); + } + return escapeToPlainHtml(preprocessed); + } + + // Configure marked options defensively + if (typeof window.marked?.use === 'function') { + // Create a lightweight renderer with restricted features + const renderer = new window.marked.Renderer(); + + // Links: target _blank + rel attributes; block unsafe protocols + renderer.link = (href, title, text) => { + const safeHref = sanitizeUrl(href); + if (!safeHref) { + return text; // drop link, keep text + } + const t = opts.linkTargetBlank ? ' target="_blank"' : ''; + const rel = ' rel="noopener noreferrer nofollow"'; + const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; + return `${text}`; + }; + + // Images disabled: either drop or render alt text + renderer.image = (href, title, text) => { + if (!opts.enableImages) { + return text ? escapeToPlainHtml(text) : ''; + } + // If enabled in future, still sanitize + const safeHref = sanitizeUrl(href); + if (!safeHref) return text ? escapeToPlainHtml(text) : ''; + const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; + return `${escapeHtmlAttr(text || '')}`; + }; + + // Code blocks disabled + renderer.code = (code, infostring) => { + if (!opts.enableCodeBlocks) { + return `
${escapeToPlainHtml(code)}
`; // still show as plain + } + return `
${escapeToPlainHtml(code)}
`; + }; + + // Inline code disabled + renderer.codespan = (code) => { + if (!opts.allowInlineCode) { + return escapeToPlainHtml(code); + } + return `${escapeToPlainHtml(code)}`; + }; + + // Disallow raw HTML if not allowed + const mOptions = { + gfm: true, + breaks: !!opts.enableBreaks, + headerIds: false, + mangle: false, + renderer + }; + + // Table support via GFM is on by default; if disabled, override table renderers to simple paragraphs + if (!opts.enableTables) { + renderer.table = (header, body) => `${header}${body}`; + } + + const html = window.marked.parse(preprocessed, mOptions); + + // Sanitize output + const sanitized = sanitizeHtml(html); + return sanitized; + } + + // Fallback older API: window.marked is a function + const html = typeof window.marked === 'function' ? window.marked(preprocessed) : String(preprocessed); + const sanitized = sanitizeHtml(html); + return sanitized; + } catch (err) { + console.warn('renderMarkdown: error while rendering; falling back to plain text', err); + return escapeToPlainHtml(preprocessed); + } +} + +// Very small sanitizer if DOMPurify is not provided globally. Prefer window.DOMPurify if available. +function sanitizeHtml(html) { + try { + if (typeof window !== 'undefined' && window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') { + return window.DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + ADD_ATTR: ['target', 'rel', 'title'], + ALLOWED_TAGS: undefined // use default safe set + }); + } + } catch (e) { + console.warn('sanitizeHtml: DOMPurify error, using basic sanitizer', e); + } + // Basic sanitizer: strip script tags and on* attributes + return String(html) + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/ on[a-z]+="[^"]*"/gi, '') + .replace(/ on[a-z]+='[^']*'/gi, ''); +} + +// Escape plain text to safe HTML +function escapeToPlainHtml(text) { + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
'); +} + +function sanitizeUrl(href) { + if (!href) return ''; + try { + const str = String(href).trim(); + const lower = str.toLowerCase(); + // Block javascript: and data: except maybe safe data images (we disable images anyway) + if (lower.startsWith('javascript:')) return ''; + if (lower.startsWith('data:')) return ''; + if (lower.startsWith('vbscript:')) return ''; + return str; + } catch (e) { + return ''; + } +} + +function escapeHtmlAttr(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/eveai_chat_client/static/assets/vue-components/ChatMessage.vue b/eveai_chat_client/static/assets/vue-components/ChatMessage.vue index eba33d9..fe58241 100644 --- a/eveai_chat_client/static/assets/vue-components/ChatMessage.vue +++ b/eveai_chat_client/static/assets/vue-components/ChatMessage.vue @@ -95,7 +95,7 @@ -
+
@@ -126,6 +126,7 @@ import DynamicForm from './DynamicForm.vue'; import ProgressTracker from './ProgressTracker.vue'; import { useIconManager } from '../js/composables/useIconManager.js'; import { useComponentTranslations } from '../js/services/LanguageProvider.js'; +import { renderMarkdown } from '../js/utils/markdownRenderer.js'; export default { name: 'ChatMessage', @@ -236,6 +237,31 @@ export default { // Component cleanup if needed }, computed: { + renderedMessage() { + const content = this.message?.content ?? ''; + if (!content) return ''; + // Only render markdown for AI messages and text type + if (this.message.sender !== 'ai' || this.message.type !== 'text') { + // plain text fallback + return String(content) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, ''') + .replace(/\n/g, '
'); + } + return renderMarkdown(content, { + allowInlineHTML: false, + enableTables: true, + enableBreaks: true, + enableImages: false, + enableCodeBlocks: false, + allowInlineCode: false, + linkTargetBlank: true, + sidebarAccent: false + }); + }, isActiveContext() { // active if in input area or sticky area return !!(this.isInInputArea || this.isInStickyArea); @@ -600,12 +626,64 @@ export default { /* Zorgt dat het lettertype consistent is */ .message-text { + color: var(--ai-message-text-color); font-family: Arial, sans-serif; font-size: 14px; - white-space: pre-wrap; + white-space: normal; word-break: break-word; } +/* Markdown typography inside message text */ +.message-text :deep(h1), +.message-text :deep(h2), +.message-text :deep(h3), +.message-text :deep(h4) { + margin: 0.8rem 0 0.4rem; + color: var(--ai-message-text-color); +} +.message-text :deep(p) { + margin: 0 0 0.6rem 0; +} +.message-text :deep(ul), +.message-text :deep(ol) { + padding-left: 1.2rem; + margin: 0.4rem 0 0.6rem; +} +.message-text :deep(li) { + margin: 0.2rem 0; +} +.message-text :deep(a) { + color: var(--primary-color); + text-decoration: underline; +} +.message-text :deep(table) { + width: 100%; + border-collapse: collapse; + margin: 0.6rem 0; + overflow-x: auto; + display: block; +} +.message-text :deep(th), +.message-text :deep(td) { + border: 1px solid #e0e0e0; + padding: 6px 8px; + text-align: left; +} +.message-text :deep(th) { + background: rgba(0,0,0,0.05); + font-weight: 600; +} +.message-text :deep(blockquote) { + border-left: 3px solid var(--primary-color); + padding-left: 10px; + margin: 0.6rem 0; + color: var(--ai-message-text-color); + opacity: 0.9; +} +.message-text :deep(hr) { + border: 0; border-top: 1px solid #ddd; margin: 0.8rem 0; +} + /* Form error styling */ .form-error { color: red; @@ -706,26 +784,21 @@ export default { } } -/* Mobile bubble height constraints and inner scroll containment */ -@media (max-width: 768px) { - /* Default/history: apply to all message bubbles */ - .message .message-content { - max-height: 33vh; /* fallback */ - overflow-y: auto; - overscroll-behavior: contain; /* prevent scroll chaining to parent */ - -webkit-overflow-scrolling: touch; /* iOS smooth inertia */ - } - /* Active contexts (input area or sticky area): allow up to half viewport */ - .message.input-area .message-content, - .message.sticky-area .message-content { - max-height: 50vh; /* fallback */ - } +/* Bubble height constraints and inner scroll containment (apply on all viewports) */ +.message .message-content { + max-height: 33vh; /* fallback */ + overflow-y: auto; + overscroll-behavior: contain; /* prevent scroll chaining to parent */ + -webkit-overflow-scrolling: touch; /* iOS smooth inertia */ +} +/* Active contexts (input area or sticky area): allow up to half viewport */ +.message.input-area .message-content, +.message.sticky-area .message-content { + max-height: 50vh; /* fallback */ } @supports (max-height: 1svh) { - @media (max-width: 768px) { - .message .message-content { max-height: 33svh; } - .message.input-area .message-content, - .message.sticky-area .message-content { max-height: 50svh; } - } + .message .message-content { max-height: 33svh; } + .message.input-area .message-content, + .message.sticky-area .message-content { max-height: 50svh; } } \ No newline at end of file diff --git a/eveai_chat_client/static/assets/vue-components/SideBarExplanation.vue b/eveai_chat_client/static/assets/vue-components/SideBarExplanation.vue index 6072ff3..be8284a 100644 --- a/eveai_chat_client/static/assets/vue-components/SideBarExplanation.vue +++ b/eveai_chat_client/static/assets/vue-components/SideBarExplanation.vue @@ -18,6 +18,7 @@