diff --git a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue index d479444..f7e9d20 100644 --- a/frontend/ops_vue_js/src/views/aichat/AiChatView.vue +++ b/frontend/ops_vue_js/src/views/aichat/AiChatView.vue @@ -463,6 +463,128 @@ function makeTitle(items) { return text.length > 40 ? `${text.slice(0, 40)}...` : text } +const markdownCache = new Map() + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function renderInlineMarkdown(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '$1') + .replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/(?$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/__([^_]+)__/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/(^|\s)\*([^*]+)\*(?=\s|$)/g, '$1$2') + .replace(/(^|\s)_([^_]+)_(?=\s|$)/g, '$1$2') +} + +function renderMarkdown(value) { + const source = String(value || '') + if (!source) return '' + const cached = markdownCache.get(source) + if (cached) return cached + + const lines = source.replace(/\r\n/g, '\n').split('\n') + const html = [] + let paragraph = [] + + const flushParagraph = () => { + if (!paragraph.length) return + html.push(`

${renderInlineMarkdown(paragraph.join('\n')).replace(/\n/g, '
')}

`) + paragraph = [] + } + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] + const fence = line.match(/^\s*(```|~~~)\s*(.*)$/) + if (fence) { + flushParagraph() + const fenceMarker = fence[1] + const language = fence[2].trim() + const code = [] + index += 1 + while (index < lines.length && !lines[index].trim().startsWith(fenceMarker)) { + code.push(lines[index]) + index += 1 + } + const languageClass = language ? ` class="language-${escapeHtml(language)}"` : '' + html.push(`
${escapeHtml(code.join('\n'))}
`) + continue + } + + if (!line.trim()) { + flushParagraph() + continue + } + + const heading = line.match(/^(#{1,6})\s+(.+)$/) + if (heading) { + flushParagraph() + const level = heading[1].length + html.push(`${renderInlineMarkdown(heading[2])}`) + continue + } + + if (/^\s*[-*_]{3,}\s*$/.test(line)) { + flushParagraph() + html.push('
') + continue + } + + const quote = line.match(/^>\s?(.*)$/) + if (quote) { + flushParagraph() + const quotes = [quote[1]] + while (index + 1 < lines.length && /^>\s?/.test(lines[index + 1])) { + index += 1 + quotes.push(lines[index].replace(/^>\s?/, '')) + } + html.push(`
${renderInlineMarkdown(quotes.join('\n')).replace(/\n/g, '
')}
`) + continue + } + + const unordered = line.match(/^\s*[-*+]\s+(.+)$/) + if (unordered) { + flushParagraph() + const items = [unordered[1]] + while (index + 1 < lines.length && /^\s*[-*+]\s+/.test(lines[index + 1])) { + index += 1 + items.push(lines[index].replace(/^\s*[-*+]\s+/, '')) + } + html.push(``) + continue + } + + const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/) + if (ordered) { + flushParagraph() + const items = [ordered[1]] + while (index + 1 < lines.length && /^\s*\d+[.)]\s+/.test(lines[index + 1])) { + index += 1 + items.push(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + } + html.push(`
    ${items.map((item) => `
  1. ${renderInlineMarkdown(item)}
  2. `).join('')}
`) + continue + } + + paragraph.push(line) + } + + flushParagraph() + const rendered = html.join('') + if (markdownCache.size > 200) markdownCache.clear() + markdownCache.set(source, rendered) + return rendered +} + async function sendMessage() { const text = inputText.value.trim() const image = selectedImage.value @@ -777,8 +899,13 @@ async function sendMessage() { class="mb-2 max-h-64 max-w-full rounded-lg object-contain" /> -

- {{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }} +

+

+ {{ message.content }}

@@ -866,3 +993,140 @@ async function sendMessage() {
+ +