|
|
|
|
| |
| |
|
|
|
|
| import uiModule from './ui.js';
|
| import { splitTableRow } from './markdown/tableRow.js';
|
| import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
|
|
|
| var escapeHtml = uiModule.esc;
|
|
|
| function safeLinkUrl(rawUrl) {
|
| const url = String(rawUrl || '').trim();
|
| if (url.startsWith('#')) {
|
| return /^#[A-Za-z0-9_-]*$/.test(url) ? url : '';
|
| }
|
| try {
|
| const parsed = new URL(url, window.location.origin);
|
| if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
| return parsed.href;
|
| }
|
| } catch (_) {
|
| return '';
|
| }
|
| return '';
|
| }
|
|
|
| function linkHtml(text, url) {
|
| const safeUrl = safeLinkUrl(url);
|
| const safeText = escapeHtml(text);
|
| if (!safeUrl) return safeText;
|
| if (safeUrl.startsWith('#')) {
|
| return `<a href="${safeUrl}" class="chat-link">${safeText}</a>`;
|
| }
|
| return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
| }
|
|
|
| function _isModelEndpointUrl(rawUrl) {
|
| try {
|
| const parsed = new URL(String(rawUrl || ''), window.location.origin);
|
| if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
| const path = parsed.pathname.replace(/\/+$/, '');
|
| return path === '/v1';
|
| } catch (_) {
|
| return false;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const _ALLOWED_HTML_BAD_TAGS = new Set([
|
| 'SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',
|
| 'STYLE', 'BASE', 'FORM', 'NOSCRIPT', 'TEMPLATE',
|
|
|
|
|
|
|
|
|
|
|
| 'SVG', 'MATH',
|
| ]);
|
| const _ALLOWED_HTML_URL_ATTRS = new Set([
|
| 'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
| ]);
|
|
|
| function _compactUrlSchemeValue(value) {
|
| return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
| }
|
|
|
| function _isDangerousUrl(value) {
|
| return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value));
|
| }
|
|
|
| function _isDangerousSrcset(value) {
|
| return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
|
| }
|
|
|
| function _cleanAllowedHtmlOnce(htmlString) {
|
| const tpl = document.createElement('template');
|
| tpl.innerHTML = htmlString;
|
| for (const el of Array.from(tpl.content.querySelectorAll('*'))) {
|
|
|
|
|
|
|
| if (_ALLOWED_HTML_BAD_TAGS.has(el.tagName.toUpperCase())) {
|
| el.remove();
|
| continue;
|
| }
|
| for (const attr of Array.from(el.attributes)) {
|
| const name = attr.name.toLowerCase();
|
|
|
|
|
| if (name.startsWith('on') || name === 'srcdoc') {
|
| el.removeAttribute(attr.name);
|
| continue;
|
| }
|
| if (name === 'style') {
|
| const value = _compactUrlSchemeValue(attr.value);
|
| if (/javascript:|vbscript:|data:|expression\(/.test(value)) {
|
| el.removeAttribute(attr.name);
|
| }
|
| continue;
|
| }
|
|
|
|
|
| if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
|
| if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
|
| el.removeAttribute(attr.name);
|
| }
|
| }
|
| }
|
| }
|
| return tpl.innerHTML;
|
| }
|
|
|
| function sanitizeAllowedHtml(html) {
|
| const raw = String(html == null ? '' : html);
|
|
|
|
|
| if (typeof document === 'undefined') return escapeHtml(raw);
|
|
|
|
|
|
|
| let out = raw;
|
| for (let i = 0; i < 4; i++) {
|
| const next = _cleanAllowedHtmlOnce(out);
|
| if (next === out) break;
|
| out = next;
|
| }
|
| return out;
|
| }
|
|
|
| |
| |
|
|
| export function hasUnclosedThinkTag(text) {
|
| text = text || '';
|
| const openCount =
|
| (text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
|
| + (text.match(/<\|channel>thought/gi) || []).length;
|
| const closeCount =
|
| (text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
|
| + (text.match(/<channel\|>/gi) || []).length;
|
| return openCount > closeCount;
|
| }
|
|
|
| export function startsWithReasoningPrefix(text) {
|
| return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
|
| }
|
|
|
| export function normalizeThinkingMarkup(text) {
|
| if (!text) return text;
|
| let normalized = text;
|
| normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
| normalized = normalized.replace(/<\/thought>/gi, '</think>');
|
| normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
|
| const thought = String(content || '').trim();
|
| return thought ? `<think>${thought}</think>\n` : '';
|
| });
|
| normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
|
| normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
|
| normalized = normalized.replace(/<channel\|>/gi, '');
|
| return normalized;
|
| }
|
|
|
| function normalizePlainThinking(text) {
|
| if (!text) return text;
|
| text = normalizeThinkingMarkup(text);
|
| if (/<think/i.test(text)) return text;
|
|
|
| const trimmed = text.trimStart();
|
| if (!startsWithReasoningPrefix(trimmed)) return text;
|
|
|
| const replyStarts = [
|
| 'Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK',
|
| 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome',
|
| 'Good ', "I'm happy", "I'd be"
|
| ];
|
| const prefixRegex = /^(thinking(?:\s+process)?\s*:)\s*/i;
|
| const escapedReplyStarts = replyStarts.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
| const boundaryRegex = new RegExp(
|
| `^([\\s\\S]*?)(\\n\\n(?=${escapedReplyStarts.join('|')}|I |What|Let|This |As ))[\\s\\S]*$`,
|
| 'i'
|
| );
|
| const boundaryMatch = boundaryRegex.exec(trimmed);
|
|
|
| if (boundaryMatch) {
|
| const thinkBlock = boundaryMatch[1].replace(prefixRegex, '').trim();
|
| const reply = trimmed.slice(boundaryMatch[1].length).trimStart();
|
| if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n\n${reply}`;
|
| }
|
|
|
| const lines = trimmed.split('\n');
|
| for (let index = 1; index < lines.length; index += 1) {
|
| const line = lines[index].trim();
|
| if (!line) continue;
|
| if (replyStarts.some((prefix) => line.startsWith(prefix))) {
|
| const thinkBlock = lines.slice(0, index).join('\n').replace(prefixRegex, '').trim();
|
| const reply = lines.slice(index).join('\n').trim();
|
| if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n${reply}`;
|
| }
|
| }
|
|
|
| const withoutPrefix = trimmed.replace(prefixRegex, '');
|
| for (const prefix of replyStarts) {
|
| const rx = new RegExp(`[.!?]\\s*(${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`);
|
| const match = rx.exec(withoutPrefix);
|
| if (match && match.index > 20) {
|
| const thinkBlock = withoutPrefix.slice(0, match.index + 1).trim();
|
| const reply = withoutPrefix.slice(match.index + 1).trim();
|
| if (thinkBlock && reply) return `<think>${thinkBlock}</think>\n${reply}`;
|
| }
|
| }
|
|
|
| return text;
|
| }
|
|
|
| |
| |
|
|
| export function extractThinkingBlocks(text) {
|
|
|
|
|
|
|
| let normalized = normalizePlainThinking(text);
|
|
|
|
|
| normalized = normalized.replace(/<think(?:ing)?(?:\s+[^>]*)?>.{0,30}<\/think(?:ing)?>\s*([\s\S]*?)<\/think(?:ing)?>/gi, (m, content) => {
|
| return '<think>' + content.trim() + '</think>';
|
| });
|
|
|
|
|
| normalized = normalized.replace(/<\/think(?:ing)?>\s*<think(?:ing)?(?:\s+[^>]*)?>/gi, '\n\n');
|
|
|
|
|
| const timeMatch = normalized.match(/<think(?:ing)?\s+time="([\d.]+)"/i);
|
| const thinkingTime = timeMatch ? timeMatch[1] : null;
|
|
|
| normalized = normalized.replace(/<think(?:ing)?\s+time="[\d.]+"/gi, '<think');
|
|
|
| const thinkRegex = /<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*?)<\/think(?:ing)?>/gi;
|
| const thinkingBlocks = [];
|
| let match;
|
|
|
|
|
| while ((match = thinkRegex.exec(normalized)) !== null) {
|
| const content = match[1].trim();
|
| if (content) thinkingBlocks.push(content);
|
| }
|
|
|
|
|
| let cleanContent = normalized.replace(thinkRegex, '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if (hasUnclosedThinkTag(normalized)) {
|
| const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
|
| if (gemmaThoughtStart >= 0) {
|
| const leakedThought = cleanContent
|
| .slice(gemmaThoughtStart)
|
| .replace(/^<\|channel>thought\s*\n?/i, '')
|
| .trim();
|
| if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
|
| cleanContent = cleanContent.slice(0, gemmaThoughtStart);
|
| } else {
|
| const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
| if (strayOpener) {
|
| cleanContent = strayOpener[1];
|
| } else {
|
| cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
| }
|
| }
|
| }
|
|
|
|
|
| const orphanMatch = cleanContent.match(/^([\s\S]+?)<\/think(?:ing)?>/i);
|
| if (orphanMatch && orphanMatch[1].trim()) {
|
| thinkingBlocks.push(orphanMatch[1].trim());
|
| cleanContent = cleanContent.slice(orphanMatch[0].length);
|
| }
|
|
|
|
|
| cleanContent = cleanContent.replace(/<\/think(?:ing)?>/gi, '');
|
|
|
|
|
| const mergedBlocks = thinkingBlocks.length > 1
|
| ? [thinkingBlocks.join('\n\n')]
|
| : thinkingBlocks;
|
|
|
| return {
|
| thinkingBlocks: mergedBlocks,
|
| content: cleanContent.trim(),
|
| thinkingTime,
|
| };
|
| }
|
|
|
| |
| |
|
|
| function createThinkingSection(thinkingContent, index = 0, thinkingTime = null) {
|
| const id = `thinking-${Date.now()}-${index}`;
|
| const timeHtml = thinkingTime ? `<span style="font-size:11px;opacity:0.4;font-variant-numeric:tabular-nums;">${thinkingTime}s</span>` : '';
|
| return `
|
| <div class="thinking-section">
|
| <div class="thinking-header" data-thinking-id="${id}">
|
| <div class="thinking-header-left">
|
| <span>View thinking process</span>
|
| </div>
|
| <div style="display:flex;align-items:center;gap:6px;">
|
| ${timeHtml}
|
| <span class="thinking-toggle" id="${id}-toggle"></span>
|
| </div>
|
| </div>
|
| <div class="thinking-content" id="${id}">
|
| <div class="thinking-content-inner">
|
| ${mdToHtml(thinkingContent)}
|
| </div>
|
| </div>
|
| </div>
|
| `;
|
| }
|
|
|
| function createTaskCompletedMarker() {
|
| return `
|
| <div class="task-completed-marker" role="status" aria-label="Task completed">
|
| <span class="task-completed-icon" aria-hidden="true">
|
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
| </span>
|
| <span>Task completed</span>
|
| </div>
|
| `;
|
| }
|
|
|
| |
| |
|
|
|
|
|
|
|
|
|
|
| const _EMOJI_RE = /\p{Extended_Pictographic}/u;
|
| const _emojiSeg = (typeof Intl !== 'undefined' && Intl.Segmenter)
|
| ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
|
|
| function _emojiCodepoints(emoji) {
|
|
|
| const s = emoji.indexOf('') >= 0 ? emoji : emoji.replace(/️/g, '');
|
| const cps = [];
|
| for (const ch of s) { const c = ch.codePointAt(0); if (c) cps.push(c.toString(16)); }
|
| return cps.join('-');
|
| }
|
| function _emojiImg(emoji) {
|
| const code = _emojiCodepoints(emoji);
|
| if (!code) return emoji;
|
|
|
|
|
|
|
|
|
| return `<span class="emoji" role="img" aria-label="${emoji}" style="--em:url('/api/emoji/${code}.svg')"></span>`;
|
| }
|
| function _svgifyText(text) {
|
| if (!_emojiSeg) return text;
|
| let out = '';
|
| for (const { segment } of _emojiSeg.segment(text)) {
|
| out += _EMOJI_RE.test(segment) ? _emojiImg(segment) : segment;
|
| }
|
| return out;
|
| }
|
|
|
| function _useSvgEmoji() {
|
| return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| export function svgifyEmoji(html, opts) {
|
| if (!_useSvgEmoji() || !html) return html;
|
| const allowShortcodes = !opts || opts.shortcodes !== false;
|
|
|
|
|
| const hasUnicode = _EMOJI_RE.test(html);
|
| const hasShortcode = allowShortcodes && hasEmojiShortcode(html);
|
| if (!hasUnicode && !hasShortcode) return html;
|
| const parts = html.split(/(<[^>]*>)/);
|
| let codeDepth = 0;
|
| for (let i = 0; i < parts.length; i++) {
|
| if (i % 2 === 1) {
|
| const t = parts[i].toLowerCase();
|
| if (/^<(pre|code)[\s>]/.test(t)) codeDepth++;
|
| else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
|
| continue;
|
| }
|
| if (codeDepth !== 0) continue;
|
| let seg = parts[i];
|
|
|
|
|
| if (hasShortcode) seg = replaceEmojiShortcodes(seg);
|
| if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg);
|
| parts[i] = seg;
|
| }
|
| return parts.join('');
|
| }
|
| |
| |
| |
| |
| |
|
|
| export function createCollapsible(contentMarkdown, label = 'details') {
|
| const id = `collapse-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
| const safeLabel = escapeHtml(label);
|
| return `
|
| <div class="thinking-section">
|
| <div class="thinking-header" data-thinking-id="${id}">
|
| <div class="thinking-header-left"><span data-label="${safeLabel}">View ${safeLabel}</span></div>
|
| <div style="display:flex;align-items:center;gap:6px;"><span class="thinking-toggle" id="${id}-toggle"></span></div>
|
| </div>
|
| <div class="thinking-content" id="${id}"><div class="thinking-content-inner">${mdToHtml(contentMarkdown)}</div></div>
|
| </div>`;
|
| }
|
|
|
| export function processWithThinking(text) {
|
| const { thinkingBlocks, content, thinkingTime } = extractThinkingBlocks(text);
|
|
|
| let html = '';
|
| let visibleContent = content || '';
|
| const doneOnly = /^\s*\[DONE\]\s*$/i.test(visibleContent);
|
| const hadTrailingDone = !doneOnly && /(?:^|\n)\s*\[DONE\]\s*$/i.test(visibleContent);
|
|
|
|
|
| thinkingBlocks.forEach((block, index) => {
|
| html += createThinkingSection(block, index, thinkingTime);
|
| });
|
|
|
|
|
| if (doneOnly) {
|
| html += createTaskCompletedMarker();
|
| } else {
|
| if (hadTrailingDone) visibleContent = visibleContent.replace(/\n?\s*\[DONE\]\s*$/i, '').trimEnd();
|
| if (visibleContent) html += mdToHtml(visibleContent);
|
| if (hadTrailingDone) html += createTaskCompletedMarker();
|
| }
|
|
|
| return _useSvgEmoji() ? svgifyEmoji(html) : html;
|
| }
|
|
|
| |
| |
|
|
| export function mdToHtml(src, opts) {
|
| const allowedHtmlBlocks = [];
|
| const codeBlocks = [];
|
| const mermaidBlocks = [];
|
| let s = (src ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
|
| s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
| const cleaned = code
|
| .replace(/\r\n/g, '\n')
|
| .replace(/[ \t]+$/gm, '')
|
| .replace(/^\s*\n+/, '')
|
| .replace(/\n+\s*$/g, '');
|
|
|
|
|
| if (lang && lang.toLowerCase() === 'mermaid') {
|
| const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
|
| const raw = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
| const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
|
| mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
|
| return placeholder;
|
| }
|
|
|
| const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
| const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
|
|
|
| const langClass = lang ? ` class="language-${lang}"` : '';
|
| const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
|
| const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
|
| ? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
|
| : '';
|
| const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
|
| codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
|
|
|
| return placeholder;
|
| });
|
|
|
|
|
|
|
|
|
|
|
|
|
| const ANCHOR_KIND = '(?:session|document|note|image|email|event|task|skill|research)';
|
|
|
|
|
| s = s.replace(
|
| new RegExp(`\\[([^\\]\\n]+?)\\]\\s*\\[#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\]`, 'g'),
|
| '[$1](#$2)',
|
| );
|
|
|
|
|
| s = s.replace(
|
| new RegExp(`\\[#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\]`, 'g'),
|
| '[→ open](#$1)',
|
| );
|
|
|
|
|
|
|
| s = s.replace(
|
| new RegExp(`(^|[^\\[(])#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\b`, 'g'),
|
| '$1[#$2](#$2)',
|
| );
|
|
|
|
|
|
|
| s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
| return linkHtml(text, url);
|
| });
|
|
|
|
|
|
|
| s = s.replace(
|
| /(^|[\s(<])(https?:\/\/[^\s<>"'`\]]+[^\s<>"'`\].,;:!?])/g,
|
| (match, prefix, url) => `${prefix}${linkHtml(url, url)}`
|
| );
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| s = s.replace(
|
| /(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi,
|
| (match, prefix, domain) => {
|
| const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0];
|
| const core = trail ? domain.slice(0, -trail.length) : domain;
|
| return `${prefix}${linkHtml(core, 'https://' + core)}${trail}`;
|
| }
|
| );
|
|
|
|
|
|
|
| s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
|
| const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
| allowedHtmlBlocks.push(sanitizeAllowedHtml(match.replace(/<details>/i, '<details open>')));
|
| return placeholder;
|
| });
|
|
|
|
|
| s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
|
| const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
| allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
|
| return placeholder;
|
| });
|
|
|
|
|
| s = s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
| s = s.replace(/\n{3,}/g, '\n\n');
|
|
|
|
|
| const mathBlocks = [];
|
| if (window.katex) {
|
|
|
|
|
| s = s.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => {
|
| try {
|
| const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
| mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
| return placeholder;
|
| } catch (e) { return match; }
|
| });
|
|
|
|
|
| s = s.replace(/\\\(([^\n]*?)\\\)/g, (match, math) => {
|
| try {
|
| const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
| mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
| return placeholder;
|
| } catch (e) { return match; }
|
| });
|
|
|
| s = s.replace(/\$\$([\s\S]*?)\$\$/g, (match, math) => {
|
| try {
|
| const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
| mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: true, throwOnError: false }));
|
| return placeholder;
|
| } catch (e) { return match; }
|
| });
|
|
|
| s = s.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, math) => {
|
| try {
|
| const raw = math.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
| const placeholder = `___MATH_BLOCK_${mathBlocks.length}___`;
|
| mathBlocks.push(katex.renderToString(raw.trim(), { displayMode: false, throwOnError: false }));
|
| return placeholder;
|
| } catch (e) { return match; }
|
| });
|
| }
|
|
|
|
|
| s = s.replace(/(?:^|\n)([^\n]*\|[^\n]*\|[^\n]*)(?:\n([^\n]*\|[^\n]*\|[^\n]*))*/g, (table) => {
|
| if (table.includes('___CODE_BLOCK_') || table.includes('___ALLOWED_HTML_')) return table;
|
|
|
| const rows = table.trim().split('\n');
|
| if (rows.length < 2) return table;
|
|
|
| let html = '<table style="border-collapse: collapse; width: 100%; margin: 10px 0;">';
|
|
|
| rows.forEach((row, idx) => {
|
| if (idx === 1 && /^[\s|:\-]+$/.test(row)) {
|
| html += '<tbody>';
|
| return;
|
| }
|
| const cells = splitTableRow(row);
|
| if (cells.length === 0) return;
|
|
|
| html += '<tr>';
|
|
|
| cells.forEach(cell => {
|
| const tag = idx === 0 ? 'th' : 'td';
|
| html += `<${tag} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`;
|
| });
|
|
|
| html += '</tr>';
|
| });
|
|
|
| html += '</tbody></table>';
|
| return html;
|
| });
|
|
|
|
|
| s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
| if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
|
| return `<code>${code}</code>`;
|
| });
|
|
|
|
|
| s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
|
|
|
|
|
| s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
| s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
|
|
|
|
| s = s.replace(/^###### (.*)$/gm, '<h6>$1</h6>')
|
| .replace(/^##### (.*)$/gm, '<h5>$1</h5>')
|
| .replace(/^#### (.*)$/gm, '<h4>$1</h4>')
|
| .replace(/^### (.*)$/gm, '<h3>$1</h3>')
|
| .replace(/^## (.*)$/gm, '<h2>$1</h2>')
|
| .replace(/^# (.*)$/gm, '<h1>$1</h1>');
|
|
|
|
|
| s = s.replace(/^(\d+)\. (.*)$/gm, '<oli>$2</oli>');
|
| s = s.replace(/(?:^|\n)(<oli>[\s\S]*?)(?=\n(?!<oli>)|$)/g, m => `<ol>${m.trim().replace(/<\/?oli>/g, (t) => t === '<oli>' ? '<li>' : '</li>')}</ol>`);
|
|
|
|
|
|
|
|
|
|
|
| s = s.replace(/^(?:- |\* )\[([ xX])\] (.*)$/gm, (_m, mark, text) => {
|
| const done = mark.toLowerCase() === 'x';
|
| return `<uli class="task-item${done ? ' task-done' : ''}"><span class="task-check" aria-hidden="true"></span><span class="task-text">${text}</span></uli>`;
|
| });
|
|
|
|
|
|
|
| s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>');
|
| s = s.replace(/(^|\n)((?:<uli\b[^>]*>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
|
| `${prefix}<ul>${block.trim().replace(/<uli\b([^>]*)>/g, '<li$1>').replace(/<\/uli>/g, '</li>')}</ul>`);
|
|
|
|
|
| s = s.replace(/^> (.*)$/gm, '<bq>$1</bq>');
|
| s = s.replace(/(?:^|\n)(<bq>[\s\S]*?)(?=\n(?!<bq>)|$)/g, m =>
|
| `<blockquote>${m.trim().replace(/<\/?bq>/g, (t) => t === '<bq>' ? '<p>' : '</p>')}</blockquote>`);
|
|
|
|
|
| s = s.replace(/^(?!<h\d|<ul>|<ol>|<li|<oli>|<\/li>|<pre>|<blockquote>|<bq>|<hr>|___CODE_BLOCK_|___ALLOWED_HTML_|___MATH_BLOCK_|___MERMAID_BLOCK_)([^\n]+)$/gm, '<p>$1</p>');
|
|
|
|
|
| s = s.replace(/<p>([\s\S]*?)<\/p>/g, (match, content) => {
|
| if (content.includes('___CODE_BLOCK_') || content.includes('___ALLOWED_HTML_') || content.includes('___MATH_BLOCK_') || content.includes('___MERMAID_BLOCK_')) return match;
|
| const withLineBreaks = content.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>');
|
| return `<p>${withLineBreaks}</p>`;
|
| });
|
|
|
|
|
| s = s.replace(/<p><\/p>/g, '');
|
|
|
|
|
| allowedHtmlBlocks.forEach((block, index) => {
|
| s = s.replace(`___ALLOWED_HTML_${index}___`, block);
|
| });
|
|
|
|
|
| mathBlocks.forEach((block, index) => {
|
| s = s.replace(`___MATH_BLOCK_${index}___`, block);
|
| });
|
|
|
|
|
| mermaidBlocks.forEach((block, index) => {
|
| s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
|
| });
|
|
|
|
|
| codeBlocks.forEach((block, index) => {
|
| s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
| });
|
|
|
| return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
| }
|
|
|
| |
| |
|
|
| export function squashOutsideCode(s) {
|
| if (!s) return "";
|
| const parts = String(s).split(/```/);
|
| for (let i = 0; i < parts.length; i += 2) {
|
| parts[i] = parts[i]
|
| .replace(/\r\n/g, '\n')
|
| .replace(/[ \t]+\n/g, '\n')
|
| .replace(/\n{3,}/g, '\n\n');
|
| }
|
| return parts.join('```');
|
| }
|
|
|
| |
| |
|
|
| export function renderContent(content) {
|
| if (Array.isArray(content)) {
|
| const texts = [];
|
| for (const blk of content) {
|
| if (blk.type === 'text') texts.push(blk.text);
|
| else if (blk.type === 'image_url') texts.push('[image]');
|
| }
|
| return texts.join('\n');
|
| }
|
| return content;
|
| }
|
|
|
| |
| |
|
|
| export function renderMermaid(container) {
|
| if (!window.mermaid) return;
|
| initMermaid();
|
| const target = container || document;
|
| const pending = target.querySelectorAll('pre.mermaid:not([data-processed])');
|
| if (pending.length === 0) return;
|
| try {
|
| window.mermaid.run({ nodes: pending });
|
| } catch (e) {
|
| console.warn('Mermaid render error:', e);
|
| }
|
| }
|
|
|
| const markdownModule = {
|
| escapeHtml,
|
| mdToHtml,
|
| squashOutsideCode,
|
| renderContent,
|
| processWithThinking,
|
| createCollapsible,
|
| hasUnclosedThinkTag,
|
| extractThinkingBlocks,
|
| normalizeThinkingMarkup,
|
| startsWithReasoningPrefix,
|
| renderMermaid
|
| };
|
|
|
| export default markdownModule;
|
|
|
|
|
| function initMermaid() {
|
| if (!window.mermaid || window.__odysseusMermaidReady) return;
|
| window.mermaid.initialize({ startOnLoad: false, theme: 'dark', securityLevel: 'loose' });
|
| window.__odysseusMermaidReady = true;
|
| }
|
| window.odysseusInitMermaid = initMermaid;
|
| initMermaid();
|
|
|
|
|
|
|
|
|
|
|
|
|
| const THINK_EXPANDED_KEY = 'odysseus-thinking-expanded';
|
| function _loadExpandedSet() {
|
| try { return new Set(JSON.parse(localStorage.getItem(THINK_EXPANDED_KEY) || '[]')); }
|
| catch { return new Set(); }
|
| }
|
| function _saveExpandedSet(set) {
|
| try {
|
| const arr = [...set];
|
|
|
| if (arr.length > 200) arr.splice(0, arr.length - 200);
|
| localStorage.setItem(THINK_EXPANDED_KEY, JSON.stringify(arr));
|
| } catch {}
|
| }
|
| function _hashThinkingContent(el) {
|
| if (!el) return '';
|
| const text = (el.textContent || '').trim();
|
| if (!text) return '';
|
| let h = 0;
|
| for (let i = 0; i < text.length; i++) {
|
| h = (h * 31 + text.charCodeAt(i)) | 0;
|
| }
|
| return String(h);
|
| }
|
| function _setThinkingExpanded(content, toggle, header, expanded) {
|
| if (!content || !toggle) return;
|
| content.classList.toggle('expanded', expanded);
|
| toggle.classList.toggle('expanded', expanded);
|
| const label_el = header?.querySelector('.thinking-header-left span');
|
| if (label_el) {
|
| const label = label_el.dataset.label || 'thinking process';
|
| label_el.textContent = expanded ? `Hide ${label}` : `View ${label}`;
|
| }
|
| }
|
|
|
|
|
| document.addEventListener('click', function(e) {
|
| const header = e.target.closest('.thinking-header[data-thinking-id]');
|
| if (!header) return;
|
| const id = header.dataset.thinkingId;
|
| const content = document.getElementById(id);
|
| const toggle = document.getElementById(id + '-toggle');
|
| if (!content || !toggle) return;
|
|
|
| const willExpand = !content.classList.contains('expanded');
|
| _setThinkingExpanded(content, toggle, header, willExpand);
|
|
|
|
|
| const hash = _hashThinkingContent(content);
|
| if (!hash) return;
|
| const set = _loadExpandedSet();
|
| if (willExpand) set.add(hash);
|
| else set.delete(hash);
|
| _saveExpandedSet(set);
|
| });
|
|
|
|
|
|
|
| (function _watchThinking() {
|
| if (window._thinkingWatcherWired) return;
|
| window._thinkingWatcherWired = true;
|
| const _apply = (root) => {
|
| if (!root || !root.querySelectorAll) return;
|
| const sections = root.matches?.('.thinking-section')
|
| ? [root]
|
| : [...root.querySelectorAll('.thinking-section')];
|
| if (!sections.length) return;
|
| const set = _loadExpandedSet();
|
| if (!set.size) return;
|
| for (const sec of sections) {
|
| const content = sec.querySelector('.thinking-content');
|
| if (!content) continue;
|
| if (content.classList.contains('expanded')) continue;
|
| const hash = _hashThinkingContent(content);
|
| if (!hash || !set.has(hash)) continue;
|
| const header = sec.querySelector('.thinking-header[data-thinking-id]');
|
| const id = header?.dataset.thinkingId;
|
| const toggle = id ? document.getElementById(id + '-toggle') : null;
|
| _setThinkingExpanded(content, toggle, header, true);
|
| }
|
| };
|
| const start = () => {
|
| const root = document.body;
|
| if (!root) return;
|
| _apply(root);
|
| new MutationObserver((mutations) => {
|
| for (const m of mutations) {
|
| for (const node of m.addedNodes) {
|
| if (node.nodeType === 1) _apply(node);
|
| }
|
| }
|
| }).observe(root, { childList: true, subtree: true });
|
| };
|
| if (document.readyState === 'loading') {
|
| document.addEventListener('DOMContentLoaded', start, { once: true });
|
| } else {
|
| start();
|
| }
|
| })();
|
|
|
| function _endpointNameFromUrl(url) {
|
| try {
|
| const parsed = new URL(url, window.location.origin);
|
| return parsed.host || parsed.hostname || 'Model endpoint';
|
| } catch (_) {
|
| return 'Model endpoint';
|
| }
|
| }
|
|
|
| function _appendEndpointAddButtons(root) {
|
| if (!root || !root.querySelectorAll) return;
|
| const anchors = root.matches?.('a[href]')
|
| ? [root]
|
| : [...root.querySelectorAll('a[href]')];
|
| for (const anchor of anchors) {
|
| if (anchor.dataset.endpointAddChecked === '1') continue;
|
| anchor.dataset.endpointAddChecked = '1';
|
| const href = anchor.getAttribute('href') || '';
|
| if (!_isModelEndpointUrl(href)) continue;
|
| if (anchor.nextElementSibling?.classList?.contains('model-endpoint-add-btn')) continue;
|
|
|
| const btn = document.createElement('button');
|
| btn.type = 'button';
|
| btn.className = 'model-endpoint-add-btn';
|
| btn.dataset.endpointUrl = new URL(href, window.location.origin).href.replace(/\/+$/, '');
|
| btn.title = 'Add this OpenAI-compatible endpoint to the model picker';
|
| btn.innerHTML = '<span aria-hidden="true">+</span><span>Add to model picker</span>';
|
| anchor.insertAdjacentElement('afterend', btn);
|
| }
|
| }
|
|
|
| async function _registerEndpointFromButton(btn) {
|
| const baseUrl = String(btn?.dataset?.endpointUrl || '').trim();
|
| if (!baseUrl || !_isModelEndpointUrl(baseUrl)) return;
|
| const original = btn.innerHTML;
|
| btn.disabled = true;
|
| btn.innerHTML = '<span aria-hidden="true">...</span><span>Adding</span>';
|
| try {
|
| const existingRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
| if (existingRes.ok) {
|
| const endpoints = await existingRes.json();
|
| const existing = Array.isArray(endpoints)
|
| ? endpoints.find((ep) => String(ep.base_url || '').replace(/\/+$/, '') === baseUrl)
|
| : null;
|
| if (existing) {
|
| btn.classList.add('added');
|
| btn.innerHTML = '<span aria-hidden="true">✓</span><span>Already added</span>';
|
| window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl } }));
|
| if (window.modelsModule?.refreshModels) window.modelsModule.refreshModels(true);
|
| if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
| uiModule.showToast?.(`Already in model picker: ${existing.name || _endpointNameFromUrl(baseUrl)}`);
|
| return;
|
| }
|
| }
|
|
|
| const parsed = new URL(baseUrl, window.location.origin);
|
| const fd = new FormData();
|
| fd.append('base_url', baseUrl);
|
| fd.append('name', _endpointNameFromUrl(baseUrl));
|
| fd.append('model_type', 'llm');
|
| fd.append('endpoint_kind', 'auto');
|
| fd.append('skip_probe', 'true');
|
| if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0)$/i.test(parsed.hostname)) {
|
| fd.append('container_local', 'true');
|
| }
|
| const res = await fetch('/api/model-endpoints', {
|
| method: 'POST',
|
| credentials: 'same-origin',
|
| body: fd,
|
| });
|
| if (!res.ok) {
|
| const body = await res.text().catch(() => '');
|
| throw new Error(`HTTP ${res.status}${body ? ': ' + body.slice(0, 160) : ''}`);
|
| }
|
| btn.classList.add('added');
|
| btn.innerHTML = '<span aria-hidden="true">✓</span><span>Added</span>';
|
| window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl } }));
|
| if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
| if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
| uiModule.showToast?.(`Model endpoint added: ${_endpointNameFromUrl(baseUrl)}`);
|
| } catch (err) {
|
| btn.disabled = false;
|
| btn.innerHTML = original;
|
| uiModule.showError?.(`Add endpoint failed: ${err.message || err}`);
|
| }
|
| }
|
|
|
| (function _watchModelEndpointLinks() {
|
| if (window._modelEndpointLinkWatcherWired) return;
|
| window._modelEndpointLinkWatcherWired = true;
|
|
|
| document.addEventListener('click', (e) => {
|
| const btn = e.target.closest?.('.model-endpoint-add-btn');
|
| if (!btn) return;
|
| e.preventDefault();
|
| e.stopPropagation();
|
| _registerEndpointFromButton(btn);
|
| });
|
|
|
| const start = () => {
|
| const root = document.body;
|
| if (!root) return;
|
| _appendEndpointAddButtons(root);
|
| new MutationObserver((mutations) => {
|
| for (const m of mutations) {
|
| for (const node of m.addedNodes) {
|
| if (node.nodeType === 1) _appendEndpointAddButtons(node);
|
| }
|
| }
|
| }).observe(root, { childList: true, subtree: true });
|
| };
|
| if (document.readyState === 'loading') {
|
| document.addEventListener('DOMContentLoaded', start, { once: true });
|
| } else {
|
| start();
|
| }
|
| })();
|
|
|