diff --git a/application/single_app/config.py b/application/single_app/config.py index 08c0adf1..be17c861 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.005" +VERSION = "0.239.011" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index f672b28f..aed6f2f3 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1085,6 +1085,8 @@ pre[class*="language-"] { display: block; /* Optional: prevent code from wrapping */ white-space: pre; + position: relative; + padding-top: 2.2rem; /* Make room for the copy button */ } /* Code element inside pre: don't expand parent */ @@ -1462,6 +1464,36 @@ ol { background-color: rgba(255, 255, 255, 0.1); } +/* Table copy toolbar */ +.table-copy-toolbar { + text-align: right; +} + +.table-copy-toolbar-top { + margin-bottom: 2px; +} + +.table-copy-toolbar-bottom { + margin-top: 2px; + margin-bottom: 8px; +} + +.table-copy-toolbar .btn { + font-size: 0.7rem; + padding: 1px 6px; + opacity: 0.5; + transition: opacity 0.2s; +} + +.table-copy-toolbar .btn:hover { + opacity: 1; +} + +[data-bs-theme="dark"] .table-copy-toolbar .btn { + color: #adb5bd; + border-color: #495057; +} + /* Code blocks within tables */ .message-text table code { background-color: rgba(0, 0, 0, 0.1); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d4c54790..13269105 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -33,6 +33,143 @@ if (typeof window.appSettings !== 'undefined' && window.appSettings.enable_text_ }); } +/** + * Ensures markdown table content has proper separator lines. + * This fixes cases where LLM output has pipe-delimited rows but is missing + * the separator line (|---|---|) needed for marked.js to recognize it as a table. + * + * @param {string} content - The markdown content to process + * @returns {string} - Content with table separators ensured + */ +function ensureMarkdownTableSeparators(content) { + const lines = content.split('\n'); + const result = []; + let insideTable = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + result.push(line); + + const trimmed = line.trim(); + + // Track if this line is a separator line + if (/^\|[\s\-:|]+\|$/.test(trimmed) && trimmed.includes('-')) { + insideTable = true; + continue; + } + + // Check if this line is a pipe-delimited row + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) { + insideTable = false; + continue; + } + const cells = trimmed.split('|').filter(c => c.trim() !== ''); + if (cells.length < 2) { + insideTable = false; + continue; + } + + // If we're already inside a table (past the separator), don't add more separators + if (insideTable) continue; + + // Check next line + if (i + 1 >= lines.length) continue; + const nextLine = lines[i + 1].trim(); + + // If next line is already a separator, mark inside table and skip + if (/^\|[\s\-:|]+\|$/.test(nextLine) && nextLine.includes('-')) continue; + + // If next line is another table row with same column count, insert separator + if (!nextLine.startsWith('|') || !nextLine.endsWith('|')) continue; + const nextCells = nextLine.split('|').filter(c => c.trim() !== ''); + if (nextCells.length !== cells.length) continue; + + const sep = '|' + cells.map(() => ' --- ').join('|') + '|'; + result.push(sep); + insideTable = true; + console.log('🔧 Inserted missing table separator after:', trimmed.substring(0, 80)); + } + + return result.join('\n'); +} + +/** + * Converts loose pipe-separated text into proper markdown table syntax. + * Handles LLM output like: + * Year | Population + * 2020 | 779,094 + * 2010 | 672,591 + * Where rows use pipe as a delimiter but lack leading/trailing pipes + * and the separator line required by GFM. + * + * @param {string} content - The content to process + * @returns {string} - Content with loose pipe tables converted to markdown + */ +function convertLoosePipeTableToMarkdown(content) { + const lines = content.split('\n'); + let i = 0; + const result = []; + + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Detect a potential loose-pipe row: contains | but does NOT start with | + // and is not a markdown separator line and not inside a code block marker + if ( + trimmed.includes('|') && + !trimmed.startsWith('|') && + !trimmed.startsWith('```') && + !/^[\s|\-:]+$/.test(trimmed) + ) { + const headerCells = trimmed.split('|').map(c => c.trim()); + const colCount = headerCells.length; + + // Need at least 2 columns to be a table + if (colCount >= 2) { + // Look ahead to collect consecutive rows with the same column count + const tableRows = [headerCells]; + let j = i + 1; + + while (j < lines.length) { + const nextTrimmed = lines[j].trim(); + // Stop at blank lines or lines without pipes or lines that already start with | + if (!nextTrimmed || !nextTrimmed.includes('|') || nextTrimmed.startsWith('|') || nextTrimmed.startsWith('```')) break; + // Skip if it looks like a separator + if (/^[\s|\-:]+$/.test(nextTrimmed)) { j++; continue; } + + const cells = nextTrimmed.split('|').map(c => c.trim()); + if (cells.length !== colCount) break; + tableRows.push(cells); + j++; + } + + // Require at least a header + 1 data row to treat as a table + if (tableRows.length >= 2) { + console.log('🔧 Converting loose pipe table:', colCount, 'cols,', tableRows.length, 'rows'); + let mdTable = '\n'; + // Header + mdTable += '| ' + tableRows[0].join(' | ') + ' |\n'; + // Separator + mdTable += '|' + tableRows[0].map(() => ' --- ').join('|') + '|\n'; + // Data rows + for (let r = 1; r < tableRows.length; r++) { + mdTable += '| ' + tableRows[r].join(' | ') + ' |\n'; + } + mdTable += '\n'; + result.push(mdTable); + i = j; + continue; + } + } + } + + result.push(lines[i]); + i++; + } + + return result.join('\n'); +} + /** * Unwraps markdown tables that are mistakenly wrapped in code blocks. * This fixes the issue where AI responses contain tables in code blocks, @@ -320,6 +457,203 @@ function convertASCIIDashTableToMarkdown(content) { } } +/** + * Detects unfenced code blocks in LLM output and wraps them in fenced code blocks. + * Many LLMs return code without triple-backtick fences, so marked.js treats them + * as plain text. This function identifies consecutive lines that look like code + * and wraps them in ``` fences. + * + * @param {string} content - The markdown content to process + * @returns {string} - Content with unfenced code blocks wrapped in fences + */ +function wrapUnfencedCodeBlocks(content) { + try { + const lines = content.split('\n'); + const result = []; + let inFencedBlock = false; + let codeBuffer = []; + let inDocstring = false; // Track Python triple-quote docstrings + + // Patterns that strongly indicate a line IS code + const codeStartPatterns = [ + // C-family, Java, C#, JS/TS keywords + /^\s*(public|private|protected|internal|static|class|struct|enum|interface|namespace|using)\b/, + /^\s*(function|func|fn|async\s+function|async\s+def)\b/, + /^\s*(let|const|var|val|int|float|double|string|bool|boolean|void|char|long|short|byte)\s+\w/, + /^\s*(return|throw|new|yield|await)\b/, + /^\s*(if|else if|elif|else|for|foreach|while|do|switch|case|default|break|continue)\b.*[:{]/, + /^\s*(try|catch|except|finally|raise)\b/, + /^\s*(import|from\s+\w+\s+import|require|include)\b/, + /^\s*(export|module)\b/, + // Python-specific + /^\s*def\s+\w+\s*\(/, // def function_name( + /^\s*class\s+\w+/, // class ClassName + /^\s*@\w+/, // decorators + /^\s*(print|input|len|range|enumerate|zip|map|filter|sorted|isinstance|type)\s*\(/, + /^\s*\w+\s*=\s*.+/, // variable assignment (name = value) + /^\s*(self|cls)\.\w+/, // self.attribute or cls.attribute + /^\s*"""/, // docstring start/end + /^\s*'''/, // docstring start/end (single quotes) + // Method/function calls + /^\s*\w+\.\w+\s*\(/, // obj.method( + // C-family structural + /[{};]\s*$/, // lines ending in { } or ; + /^\s*\}\s*$/, // closing brace only + /^\s*\{\s*$/, // opening brace only + /=>\s*\{?\s*$/, // arrow functions + // Comments that appear in code + /^\s*\/\/\s/, // // comment + /^\s*#(?!#)\s/, // # comment (but not ## markdown header) + /^\s*\/\*|\s*\*\//, // /* */ block comments + // C/C++ preprocessor + /^\s*#\s*(include|define|pragma|ifdef|ifndef|endif)\b/, + // Attributes/annotations + /^\s*\[[\w\s,()]+\]\s*$/, // C# attributes [HttpGet] + ]; + + function isClearlyProse(line) { + const trimmed = line.trim(); + if (!trimmed) return false; + // Markdown headers with ## or more (# alone could be a Python comment) + if (/^#{2,}\s/.test(trimmed)) return true; + // Markdown bold/italic headers like **Optional:** + if (/^\*{1,2}[A-Z]/.test(trimmed)) return true; + // Lines starting with common English sentence patterns without code indicators + if (/^(Here['']?s|This is|The |You can|To |In this|Note:|For example|An? |It |We |They |I )/i.test(trimmed) + && !trimmed.includes('{') && !trimmed.includes(';') && !trimmed.includes('(') && !/=\s/.test(trimmed)) return true; + // Markdown blockquotes and unordered lists (not indented code) + if (/^[>]/.test(trimmed)) return true; + // Markdown bullet lists at start + if (/^[-*+]\s/.test(trimmed) && !trimmed.includes('{') && !trimmed.includes(';')) return true; + return false; + } + + function looksLikeCode(line) { + const trimmed = line.trim(); + if (!trimmed) return false; + if (isClearlyProse(line)) return false; + return codeStartPatterns.some(p => p.test(line)); + } + + function looksLikeCodeContinuation(line) { + // When we're already collecting code, these lines should stay in the buffer + const trimmed = line.trim(); + if (!trimmed) return false; + if (isClearlyProse(line)) return false; + // Indented lines (4+ spaces or tab) continuing a code block + if (/^(\s{4,}|\t)/.test(line) && trimmed.length > 0) return true; + // Docstring content (inside triple quotes) + if (inDocstring) return true; + // Lines with parentheses, brackets, operators common in code + if (/[()[\]{}=<>+\-*/%]/.test(trimmed) && !/^[#>*\-+]\s/.test(trimmed)) return true; + // Looks like a parameter/type annotation line (common in docstrings and signatures) + if (/^\s*\w+\s*[\(:]/i.test(trimmed) && codeBuffer.length > 0) return true; + return false; + } + + function flushCodeBuffer() { + // Trim trailing blank lines from buffer + while (codeBuffer.length > 0 && codeBuffer[codeBuffer.length - 1].trim() === '') { + result.push(codeBuffer.pop()); + } + if (codeBuffer.length >= 2) { + const lang = detectLanguageHint(codeBuffer); + console.log(`🔧 Wrapping ${codeBuffer.length} unfenced code lines as \`\`\`${lang}`); + result.push('```' + lang); + result.push(...codeBuffer); + result.push('```'); + } else { + result.push(...codeBuffer); + } + codeBuffer = []; + inDocstring = false; + } + + function detectLanguageHint(codeLines) { + const joined = codeLines.join('\n'); + if (/\busing\s+System\b|\bnamespace\b.*{|\bConsole\.\w+/.test(joined)) return 'csharp'; + if (/\bimport\s+java\.|public\s+static\s+void\s+main\b/.test(joined)) return 'java'; + if (/\bdef\s+\w+\s*\(|\bimport\s+\w+|\bprint\s*\(|\bself\./.test(joined)) return 'python'; + if (/\b(const|let|var)\s+\w+\s*=|\bfunction\s+\w+\s*\(|\bconsole\.log\b|=>\s*\{/.test(joined)) return 'javascript'; + if (/\b(interface|type)\s+\w+\s*[{=]|:\s*(string|number|boolean)\b/.test(joined)) return 'typescript'; + if (/\bfunc\s+\w+\s*\(|\bfmt\./.test(joined)) return 'go'; + if (/\bfn\s+\w+\s*\(|\blet\s+mut\b|\bimpl\b/.test(joined)) return 'rust'; + if (/\b#include\s*<|std::|cout\s*<<|cin\s*>>/.test(joined)) return 'cpp'; + return ''; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track fenced code block state + if (trimmed.startsWith('```')) { + if (inFencedBlock) { + inFencedBlock = false; + } else { + flushCodeBuffer(); + inFencedBlock = true; + } + result.push(line); + continue; + } + + if (inFencedBlock) { + result.push(line); + continue; + } + + // Track Python docstrings (triple quotes toggle) + if (codeBuffer.length > 0) { + const tripleQuoteMatches = (trimmed.match(/"""/g) || []).length + (trimmed.match(/'''/g) || []).length; + if (tripleQuoteMatches % 2 !== 0) { + inDocstring = !inDocstring; + } + } + + // Check if this line looks like code + if (looksLikeCode(line)) { + codeBuffer.push(line); + } else if (codeBuffer.length > 0 && trimmed === '') { + // Blank line inside potential code – include it + codeBuffer.push(line); + } else if (codeBuffer.length > 0 && looksLikeCodeContinuation(line)) { + // Continuation of code (indented, has operators, inside docstring, etc.) + codeBuffer.push(line); + } else { + // Non-code line – flush any accumulated code buffer + flushCodeBuffer(); + result.push(line); + } + } + + flushCodeBuffer(); + return result.join('\n'); + + } catch (error) { + console.error('Error wrapping unfenced code blocks:', error); + return content; + } +} + +/** + * Runs the full content preprocessing pipeline on raw markdown content. + * Exported so the streaming path can also render content properly. + * + * @param {string} content - Raw markdown content + * @returns {string} - Content with all table/code formats normalized + */ +export function preprocessTableContent(content) { + let result = unwrapTablesFromCodeBlocks(content); + result = convertUnicodeTableToMarkdown(result); + result = convertPSVCodeBlockToMarkdown(result); + result = convertASCIIDashTableToMarkdown(result); + result = convertLoosePipeTableToMarkdown(result); + result = ensureMarkdownTableSeparators(result); + result = wrapUnfencedCodeBlocks(result); + return result; +} + export const userInput = document.getElementById("user-input"); const sendBtn = document.getElementById("send-btn"); const promptSelectionContainer = document.getElementById( @@ -612,13 +946,20 @@ export function appendMessage( let cleaned = messageContent.trim().replace(/\n{3,}/g, "\n\n"); cleaned = cleaned.replace(/(\bhttps?:\/\/\S+)(%5D|\])+/gi, (_, url) => url); const withInlineCitations = parseCitations(cleaned); - const withUnwrappedTables = unwrapTablesFromCodeBlocks(withInlineCitations); - const withMarkdownTables = convertUnicodeTableToMarkdown(withUnwrappedTables); - const withPSVTables = convertPSVCodeBlockToMarkdown(withMarkdownTables); - const withASCIITables = convertASCIIDashTableToMarkdown(withPSVTables); - const sanitizedHtml = DOMPurify.sanitize(marked.parse(withASCIITables)); + const withTableSeparators = preprocessTableContent(withInlineCitations); + const sanitizedHtml = DOMPurify.sanitize(marked.parse(withTableSeparators)); const htmlContent = addTargetBlankToExternalLinks(sanitizedHtml); + // Diagnostic logging for table rendering + const hasTablePipes = /\|.+\|/.test(withTableSeparators); + const hasHtmlTable = sanitizedHtml.includes('