/
GreyCat
/
openhuman
Обзор
Документация
Войти
/
GreyCat
/
openhuman
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
app/src/utils/agentMessageBubbles.ts
269 строк
9 KB
ly-wang19
fix(chat): don't fold a pipe-bearing prose line into a preceding table (#3978)
23 июн 2026, 21:47
Не верифицирован
23 июн 2026, 21:47
e15f90e
Код
Авторство
О чём код?
import debugFactory from 'debug'; const debug = debugFactory('agent-message-bubbles'); /** * Split an agent message into render-time bubble segments. * * Normalize excessive vertical whitespace first, then split only on double * newlines. Fenced code blocks stay intact as a single segment so * Markdown/code rendering does not fragment unexpectedly. * Markdown tables also stay grouped so they can render as dedicated table UI. * * Finally, regroup so a section heading and the body that follows it always * share one bubble (issue #3807): structured output such as the morning * briefing writes `## Heading\n\n- body…`, and splitting on the blank line * would otherwise orphan every heading into its own content-less bubble. */ export function splitAgentMessageIntoBubbles(content: string): string[] { const normalized = content .replace(/\r\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/\n?\s*<hr\s*\/?>\s*\n?/gi, '\n\n'); const trimmedContent = normalized.trim(); if (trimmedContent.length === 0) return []; if (!normalized.includes('\n')) { return isVisualSeparatorOnly(trimmedContent) ? [] : [trimmedContent]; } const lines = normalized.split('\n'); const segments: string[] = []; let currentLines: string[] = []; let inFence = false; const flushCurrent = () => { const segment = currentLines.join('\n').trim(); if (segment.length > 0) { segments.push(segment); } currentLines = []; }; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const trimmedLine = line.trim(); if (!inFence && isMarkdownTableStart(lines, index)) { if (currentLines.length > 0) { flushCurrent(); } // A prose line that merely contains a pipe (e.g. "pick A | B, then run // `ps aux | grep x`") satisfies looksLikeMarkdownTableRow but is not a // table row. Require the same column count as the header so it isn't // absorbed into the table — otherwise parseMarkdownTable rejects the // mismatched block (returns null), suppressing the table UI and merging // the prose line into the table bubble. // // Always keep at least the first data row, though: splitMarkdownTableCells // splits naively on "|" and does not honor escaped "\|" or pipes inside // code spans, so a genuine first row carrying such a pipe over-counts its // cells. Dropping it here would leave a header+separator-only block that // parseMarkdownTable renders as an empty-row table (and detaches the real // row). Keeping it preserves the count mismatch so parseMarkdownTable // returns null and the block falls back to readable raw markdown. const headerCellCount = splitMarkdownTableCells(line).length; const tableLines = [line, lines[index + 1]]; index += 2; let dataRowsAbsorbed = 0; while (index < lines.length && looksLikeMarkdownTableRow(lines[index])) { const matches = splitMarkdownTableCells(lines[index]).length === headerCellCount; if (!matches && dataRowsAbsorbed > 0) break; tableLines.push(lines[index]); dataRowsAbsorbed += 1; index += 1; } index -= 1; segments.push(tableLines.join('\n').trim()); continue; } if (trimmedLine.startsWith('```')) { if (!inFence && currentLines.length > 0) { flushCurrent(); } currentLines.push(line); inFence = !inFence; if (!inFence) { flushCurrent(); } continue; } if (inFence) { currentLines.push(line); continue; } if (trimmedLine.length === 0) { if (currentLines.length > 0) { flushCurrent(); } continue; } currentLines.push(line); } flushCurrent(); const cleaned = segments.filter(segment => !isVisualSeparatorOnly(segment)); const grouped = groupSectionsUnderHeadings(cleaned); debug( '[bubbles] %d char message -> %d raw segment(s) -> %d bubble(s) after heading grouping', content.length, cleaned.length, grouped.length ); return grouped; } /** * Merge each section heading with the body segments that follow it (up to the * next heading) into a single bubble, so a heading is never stranded in a * content-less bubble of its own. Segments before the first heading (e.g. a * greeting) are left untouched. * * Tables are deliberately NOT absorbed into a heading bubble: `AgentMessageBubble` * only renders the dedicated table UI when the table sits at the start of the * bubble (`parseMarkdownTable`), and the Markdown renderer has no GFM-table * support, so folding a table behind a heading would render it as raw pipe * text. A table therefore stays its own bubble and ends the current section. */ function groupSectionsUnderHeadings(segments: string[]): string[] { const grouped: string[] = []; let index = 0; while (index < segments.length) { const segment = segments[index]; if (!startsWithHeading(segment)) { grouped.push(segment); index += 1; continue; } // Absorb the body paragraphs below this heading — but stop at the next // heading or a table, both of which must begin their own bubble. const body: string[] = []; index += 1; while ( index < segments.length && !startsWithHeading(segments[index]) && !isTableSegment(segments[index]) ) { body.push(segments[index]); index += 1; } if (body.length > 0) { grouped.push([segment, ...body].join('\n\n')); continue; } // Heading with no inline body. Avoid an orphan heading bubble: when the // next segment is a table the heading labels it from the bubble directly // above (and must not be folded into the table); otherwise fold the // heading into the previous bubble, unless that bubble is itself a table. const nextIsTable = index < segments.length && isTableSegment(segments[index]); const previous = grouped[grouped.length - 1]; if (!nextIsTable && previous !== undefined && !isTableSegment(previous)) { grouped[grouped.length - 1] = `${previous}\n\n${segment}`; } else { grouped.push(segment); } } return grouped; } /** A segment "starts a section" when its first non-empty line is a heading. */ function startsWithHeading(segment: string): boolean { const firstLine = segment.split('\n').find(line => line.trim().length > 0) ?? ''; return isHeadingLine(firstLine); } /** A segment is a table when its first non-empty line begins a markdown table. */ function isTableSegment(segment: string): boolean { const lines = segment.split('\n'); let index = 0; while (index < lines.length && lines[index].trim().length === 0) index += 1; return isMarkdownTableStart(lines, index); } /** * Recognize the heading shapes structured agent output uses to label a * section: Markdown ATX headings (`#`..`######`) and a single fully-bold line * (`**Calendar**`), optionally with a trailing colon. * * The bold form deliberately requires the WHOLE line to be bold — inline * emphasis with trailing prose (`**Heads up:** the meeting moved`) is NOT a * heading, so ordinary emphasized content is not misclassified. The residual * heuristic risk is a standalone fully-bold sentence used as content; in that * case it is simply merged with the following segment (kept together, never * orphaned), which is harmless for the structured briefing shapes this serves. */ function isHeadingLine(line: string): boolean { const trimmed = line.trim(); if (/^#{1,6}\s+\S/.test(trimmed)) return true; return /^\*\*[^*]+\*\*:?$/.test(trimmed); } export interface ParsedMarkdownTable { headers: string[]; rows: string[][]; } export function parseMarkdownTable(content: string): ParsedMarkdownTable | null { const normalized = content.replace(/\r\n/g, '\n').trim(); const lines = normalized .split('\n') .map(line => line.trim()) .filter(line => line.length > 0); if (lines.length < 2) return null; if (!isMarkdownTableStart(lines, 0)) return null; const headers = splitMarkdownTableCells(lines[0]); const rows = lines.slice(2).map(splitMarkdownTableCells); if (headers.length === 0 || rows.some(row => row.length !== headers.length)) { return null; } return { headers, rows }; } function isMarkdownTableStart(lines: string[], index: number): boolean { const header = lines[index]; const separator = lines[index + 1]; if (!header || !separator) return false; return looksLikeMarkdownTableRow(header) && looksLikeMarkdownTableSeparator(separator); } function looksLikeMarkdownTableRow(line: string): boolean { const trimmed = line.trim(); if (!trimmed.includes('|')) return false; const cells = splitMarkdownTableCells(trimmed); return cells.length >= 2; } function looksLikeMarkdownTableSeparator(line: string): boolean { const cells = splitMarkdownTableCells(line); if (cells.length < 2) return false; return cells.every(cell => /^:?-{3,}:?$/.test(cell)); } function splitMarkdownTableCells(line: string): string[] { return line .trim() .replace(/^\|/, '') .replace(/\|$/, '') .split('|') .map(cell => cell.trim()); } function isVisualSeparatorOnly(segment: string): boolean { const trimmed = segment.trim(); if (trimmed.length === 0) return true; if (/^<hr\s*\/?>$/i.test(trimmed)) return true; return /^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed.replace(/\s+/g, '')); }