diff options
| author | Michael Peter Christen <mc@yacy.net> | 2026-01-04 13:19:44 +0100 |
|---|---|---|
| committer | Michael Peter Christen <mc@yacy.net> | 2026-01-04 13:19:44 +0100 |
| commit | 543775ff790ede321f66428f3f42fc37169d648f (patch) | |
| tree | d09852bfdc685e6d5cb544a844fb8903708c9923 /htroot | |
| parent | 4f6cd9aed27ec99e25936ea44b6b3de14031229e (diff) | |
added rendering of <think> tags in LLM responses.
Diffstat (limited to 'htroot')
| -rw-r--r-- | htroot/yacychat.html | 269 |
1 files changed, 259 insertions, 10 deletions
diff --git a/htroot/yacychat.html b/htroot/yacychat.html index 8bee659e3..cd56fa9a8 100644 --- a/htroot/yacychat.html +++ b/htroot/yacychat.html @@ -120,6 +120,33 @@ padding: 0; } + .think-block { + margin: 8px 0; + border: 1px solid #d6dbe5; + border-radius: 8px; + background: #f5f7fb; + padding: 6px 10px; + } + + .think-block summary { + cursor: pointer; + font-weight: 700; + list-style: none; + } + + .think-block summary::-webkit-details-marker { + display: none; + } + + .think-block[open] summary { + margin-bottom: 6px; + } + + .think-content { + color: #394b59; + font-size: 0.85rem; + } + .chat-body.markdown h1, .chat-body.markdown h2, .chat-body.markdown h3, @@ -916,12 +943,174 @@ } } + function stripThinkBlocks(text) { + if (!text || typeof text !== 'string') return text || ''; + const ranges = buildFenceRanges(text); + const openPattern = /<think\s*>/ig; + const closePattern = /<\/think\s*>/ig; + let result = ''; + let cursor = 0; + while (cursor < text.length) { + const open = findTagOutsideFences(text, cursor, openPattern, ranges); + if (!open) { + result += text.slice(cursor); + break; + } + result += text.slice(cursor, open.index); + const close = findTagOutsideFences(text, open.end, closePattern, ranges); + if (!close) { + break; + } + cursor = close.end; + } + return result; + } + + function stripThinkFromContent(content) { + if (typeof content === 'string') return stripThinkBlocks(content); + if (Array.isArray(content)) { + return content.map(part => { + if (part?.type === 'text' && typeof part.text === 'string') { + return { ...part, text: stripThinkBlocks(part.text) }; + } + return part; + }); + } + if (content && typeof content === 'object' && typeof content.text === 'string') { + return { ...content, text: stripThinkBlocks(content.text) }; + } + return content; + } + + function resolveThinkDefaultOpen(container) { + const index = Number.parseInt(container?.dataset?.messageIndex || '', 10); + const message = Number.isNaN(index) ? null : state.messages[index]; + const explicitOpen = container?.dataset?.thinkOpen; + if (explicitOpen === 'true') return true; + if (explicitOpen === 'false') return false; + if (typeof message?.thinkOpen === 'boolean') return message.thinkOpen; + if (container?.dataset?.thinkAutoClosed === 'true') return false; + return container?.dataset?.streaming === 'true'; + } + + function buildFenceRanges(text) { + const ranges = []; + if (!text || typeof text !== 'string') return ranges; + let inFence = false; + let fenceChar = ''; + let fenceStart = 0; + let offset = 0; + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fenceMatch = line.match(/^\s*(```+|~~~+)/); + if (fenceMatch) { + const marker = fenceMatch[1]; + if (!inFence) { + inFence = true; + fenceChar = marker[0]; + fenceStart = offset; + } else if (marker[0] === fenceChar) { + inFence = false; + ranges.push([fenceStart, offset + line.length + 1]); + fenceChar = ''; + } + } + offset += line.length + 1; + } + if (inFence) { + ranges.push([fenceStart, text.length]); + } + return ranges; + } + + function isIndexInFence(index, ranges) { + return ranges.some(range => index >= range[0] && index < range[1]); + } + + function findTagOutsideFences(text, start, pattern, ranges) { + const regex = new RegExp(pattern.source, pattern.flags); + regex.lastIndex = start; + let match = regex.exec(text); + while (match) { + if (!isIndexInFence(match.index, ranges)) { + return { index: match.index, end: regex.lastIndex, match }; + } + regex.lastIndex = match.index + 1; + match = regex.exec(text); + } + return null; + } + + function hasThinkCloseOutsideFences(text) { + if (!text || typeof text !== 'string') return false; + const ranges = buildFenceRanges(text); + return !!findTagOutsideFences(text, 0, /<\/think\s*>/ig, ranges); + } + + function renderAssistantContent(text, defaultOpen) { + const source = typeof text === 'string' ? text : ''; + if (!source) return ''; + const openRe = /<think\s*>/ig; + const closeRe = /<\/think\s*>/ig; + const ranges = buildFenceRanges(source); + let html = ''; + let cursor = 0; + while (cursor < source.length) { + const openMatch = findTagOutsideFences(source, cursor, openRe, ranges); + if (!openMatch) { + html += renderMarkdown(source.slice(cursor)); + break; + } + const openIndex = openMatch.index; + const openEnd = openMatch.end; + if (openIndex > cursor) { + html += renderMarkdown(source.slice(cursor, openIndex)); + } + const closeMatch = findTagOutsideFences(source, openEnd, closeRe, ranges); + const closeIndex = closeMatch ? closeMatch.index : -1; + const thinkBody = closeIndex === -1 + ? source.slice(openEnd) + : source.slice(openEnd, closeIndex); + const openAttr = defaultOpen ? ' open="open"' : ''; + html += `<details class="think-block"${openAttr}><summary>Model thoughts</summary><div class="think-content">${renderMarkdown(thinkBody)}</div></details>`; + if (!closeMatch) { + break; + } + cursor = closeMatch.end; + } + return html; + } + + function bindThinkToggles(container) { + if (!container) return; + const detailsNodes = Array.from(container.querySelectorAll('details.think-block')); + if (!detailsNodes.length) return; + const index = Number.parseInt(container.dataset.messageIndex || '', 10); + detailsNodes.forEach(details => { + details.addEventListener('toggle', () => { + if (!Number.isNaN(index)) { + const targetMessage = state.messages[index]; + if (targetMessage) { + targetMessage.thinkOpen = details.open; + persistConversation(); + } + } else { + container.dataset.thinkOpen = details.open ? 'true' : 'false'; + } + }); + }); + } + function applyMessageContent(target, role, text) { if (!target) return; if (role === 'assistant') { target.classList.add('markdown', 'markdown-body'); target.classList.remove('plain-text'); - target.innerHTML = renderMarkdown(text); + target.dataset.rawMarkdown = text || ''; + const defaultOpen = resolveThinkDefaultOpen(target); + target.innerHTML = renderAssistantContent(text, defaultOpen); + bindThinkToggles(target); rehighlightCode(target); } else { target.classList.add('plain-text'); @@ -972,6 +1161,9 @@ const body = document.createElement('div'); body.className = 'chat-body'; + if (typeof messageIndex === 'number') { + body.dataset.messageIndex = String(messageIndex); + } applyMessageContent(body, role, text); entry.appendChild(body); @@ -991,7 +1183,7 @@ copyBtn.className = 'copy-button'; copyBtn.title = 'Copy answer'; copyBtn.innerHTML = '<span class="glyphicon glyphicon-copy" aria-hidden="true"></span>'; - copyBtn.addEventListener('click', () => copyAssistant(body.textContent)); + copyBtn.addEventListener('click', () => copyAssistant(body)); entry.appendChild(copyBtn); } if (role === 'user' && typeof messageIndex === 'number') { @@ -1117,9 +1309,12 @@ applySearchDefaultToComposer(); } - async function copyAssistant(text) { + async function copyAssistant(target) { + const raw = target?.dataset?.rawMarkdown; + const source = typeof raw === 'string' ? raw : (target?.textContent || ''); + const cleaned = stripThinkBlocks(source); try { - await navigator.clipboard.writeText(text || ''); + await navigator.clipboard.writeText(cleaned || ''); } catch (err) { console.warn('Clipboard copy failed', err); } @@ -1252,9 +1447,15 @@ } async function streamChat(userMessage, assistantNode, options = {}) { - const { onFirstToken, includeUserInPayload = true, pushUserToState = true } = options; + const { onFirstToken, includeUserInPayload = true, pushUserToState = true, currentUserIndex = -1 } = options; ensureSystemMessage(); - const sanitizedMessages = state.messages.map(stripMessageForApi).filter(Boolean); + const sanitizedMessages = state.messages + .map((msg, index) => { + const hasAttachment = msg?.role === 'user' && (Array.isArray(msg.content) || msg.attachment); + const textOnly = index !== currentUserIndex && !hasAttachment; + return stripMessageForApi(msg, { textOnly }); + }) + .filter(Boolean); const sanitizedUserMessage = includeUserInPayload ? stripMessageForApi(userMessage) : null; const payload = { model: state.config.model, @@ -1282,6 +1483,12 @@ let sawFirstDelta = false; let focusShown = false; let searchApplied = false; + let thinkAutoClosed = false; + let thinkAutoCloseFired = false; + let thinkAutoCloseTimer = null; + if (assistantNode) { + assistantNode.dataset.streaming = 'true'; + } if (typeof onFirstToken === 'function' && !focusShown) { focusShown = true; onFirstToken(); @@ -1344,6 +1551,30 @@ } } assistantText += delta; + if (!thinkAutoClosed && hasThinkCloseOutsideFences(assistantText)) { + thinkAutoClosed = true; + if (assistantNode) { + assistantNode.dataset.thinkAutoClosing = 'true'; + } + if (thinkAutoCloseTimer) { + clearTimeout(thinkAutoCloseTimer); + } + thinkAutoCloseTimer = window.setTimeout(() => { + thinkAutoCloseFired = true; + if (assistantNode) { + assistantNode.dataset.thinkAutoClosing = 'false'; + assistantNode.dataset.thinkAutoClosed = 'true'; + assistantNode.dataset.thinkOpen = 'false'; + applyMessageContent(assistantNode, 'assistant', assistantText); + } + const last = state.messages[state.messages.length - 1]; + if (last && last.role === 'assistant' && last.content === assistantText) { + last.thinkOpen = false; + persistConversation(); + renderConversation({ skipScroll: true }); + } + }, 2000); + } applyMessageContent(assistantNode, 'assistant', assistantText); performAutoScroll(); } @@ -1383,7 +1614,13 @@ if (pushUserToState) { state.messages.push(userMessage); } - state.messages.push({ role: 'assistant', content: assistantText }); + const explicitThinkOpen = assistantNode?.dataset?.thinkOpen; + const storedThinkOpen = explicitThinkOpen === 'true' + ? true + : explicitThinkOpen === 'false' + ? false + : (thinkAutoClosed && !thinkAutoCloseFired); + state.messages.push({ role: 'assistant', content: assistantText, thinkOpen: storedThinkOpen }); state.assistantSeen = true; persistConversation(); updateClearChatVisibility(); @@ -1423,9 +1660,21 @@ return { role: 'user', content: promptText, search: !!state.searchMode }; } - function stripMessageForApi(message) { + function stripMessageForApi(message, options = {}) { if (!message) return null; - const sanitized = { role: message.role, content: message.content }; + const { textOnly = false } = options; + let content = message.content; + if (textOnly) { + content = messageText(content); + } + if (message.role === 'assistant') { + if (textOnly || typeof content === 'string') { + content = stripThinkBlocks(String(content || '')); + } else { + content = stripThinkFromContent(content); + } + } + const sanitized = { role: message.role, content }; if (message.search) sanitized.search = true; return sanitized; } @@ -1989,7 +2238,7 @@ resizeComposer(); const assistantNode = appendMessage('assistant', 'waiting for an answer...'); try { - await streamChat(userMessage, assistantNode, { onFirstToken: showComposer, includeUserInPayload: false, pushUserToState: false }); + await streamChat(userMessage, assistantNode, { onFirstToken: showComposer, includeUserInPayload: false, pushUserToState: false, currentUserIndex: userIndex }); } catch (err) { appendMessage('system', `Error: ${err.message}`); showComposer(); |
