/** * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../core/models/markdown.model'; import { CheckerResult } from '../core/models/stream.model'; /** * Split text into bold, code, and plain segments */ export function parseMarkdownSegments(text: string): MarkdownSegment[] { if (!text) return []; const parts = text.split(/(\*\*.*?\*\*|`.*?`)/g); return parts .filter(part => part !== '') .map(part => { if (part.startsWith('**') && part.endsWith('**')) { return { text: part.slice(2, -2), bold: true, code: false }; } else if (part.startsWith('`') && part.endsWith('`')) { return { text: part.slice(1, -1), bold: false, code: true }; } else { return { text: part, bold: false, code: false }; } }); } /** * Parse markdown note content into structured lines (checklists, headers, list items) */ export function parseNoteLines(content: string): MarkdownLine[] { if (!content) return []; return content.split('\n').map(line => { const trimmed = line.trim(); const indent = line.length - line.trimStart().length; // Checkbox [x] if (trimmed.startsWith('- [x] ') || trimmed.startsWith('- [X] ')) { const rawText = trimmed.substring(6); return { type: 'checked', segments: parseMarkdownSegments(rawText), indent }; } // Active checkbox [/] if (trimmed.startsWith('- [/] ')) { const rawText = trimmed.substring(6); return { type: 'progress', segments: parseMarkdownSegments(rawText), indent }; } // Unchecked checkbox [ ] if (trimmed.startsWith('- [ ] ')) { const rawText = trimmed.substring(6); return { type: 'unchecked', segments: parseMarkdownSegments(rawText), indent }; } // Check lines (- verify: / - assert: / - verify@end: / - assert@end:) const checkMatch = trimmed.match(/^[-*]\s*(verify|assert)(@end)?\s*:\s*(.*)$/i); if (checkMatch) { const kind = checkMatch[1].toLowerCase() as 'verify' | 'assert'; const atEnd = Boolean(checkMatch[2]); const rawText = checkMatch[3].trim(); return { type: 'verify', checkKind: kind, atEnd, segments: parseMarkdownSegments(rawText), indent }; } // System findings (- finding:) const findingMatch = trimmed.match(/^[-*]\s*finding\s*:\s*(.*)$/i); if (findingMatch) { const rawText = findingMatch[1].trim(); return { type: 'finding', checkKind: 'finding', segments: parseMarkdownSegments(rawText), indent }; } // Regular list item if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { const rawText = trimmed.substring(2); return { type: 'list-item', segments: parseMarkdownSegments(rawText), indent }; } // Headers if (trimmed.startsWith('# ')) { const rawText = trimmed.substring(2); return { type: 'h1', segments: parseMarkdownSegments(rawText), indent }; } if (trimmed.startsWith('## ')) { const rawText = trimmed.substring(3); return { type: 'h2', segments: parseMarkdownSegments(rawText), indent }; } if (trimmed.startsWith('### ')) { const rawText = trimmed.substring(4); return { type: 'h3', segments: parseMarkdownSegments(rawText), indent }; } // Default line return trimmed ? { type: 'text', segments: parseMarkdownSegments(line), indent } : { type: 'empty', segments: [], indent: 0 }; }); } /** * Group parsed markdown lines into title, milestones (checklists), and other lines */ export function parseNote(content: string): ParsedNote { const lines = parseNoteLines(content); const milestones: NoteMilestone[] = []; const otherLines: MarkdownLine[] = []; let title: string | null = null; let currentMilestone: (NoteMilestone & { _indent?: number }) | null = null; let milestoneCount = 0; for (const line of lines) { if (line.type === 'empty') { continue; } if ((line.type === 'h1' || line.type === 'h2') && !title) { title = line.segments.map(s => s.text).join(''); continue; } const isChecklistItem = ['checked', 'progress', 'unchecked'].includes(line.type); if (isChecklistItem && (!currentMilestone || line.indent <= (currentMilestone._indent ?? 0))) { milestoneCount++; currentMilestone = { index: milestoneCount, type: line.type as 'checked' | 'progress' | 'unchecked', segments: line.segments, subSteps: [], checks: [], _indent: line.indent }; milestones.push(currentMilestone); } else if (currentMilestone) { if (line.indent > (currentMilestone._indent ?? 0)) { if (line.type === 'verify' || line.type === 'assert' || line.type === 'finding') { currentMilestone.checks.push(line); } else { currentMilestone.subSteps.push(line); } } else { otherLines.push(line); } } else { otherLines.push(line); } } return { title, milestones, otherLines }; } /** * Parse the Checker JSON response block */ export function extractCheckerResult(text: string): CheckerResult | null { if (!text) return null; let cleanText = text.trim(); if (cleanText.includes('```json')) { const parts = cleanText.split('```json'); if (parts.length > 1) { cleanText = parts[1].split('```')[0].trim(); } } else if (cleanText.includes('```')) { const parts = cleanText.split('```'); if (parts.length > 1) { cleanText = parts[1].split('```')[0].trim(); } } try { const parsed = JSON.parse(cleanText); if (parsed && typeof parsed === 'object' && 'success' in parsed) { return { success: Boolean(parsed.success), reason: parsed.reason || "未提供原因。" }; } } catch { // Ignore } return null; } /** * Helper to format inline markdown segments (bold, italic, code, strikethrough) */ function formatInlineMarkdown(text: string): string { if (!text) return ''; return text // Inline code first (so asterisks inside code aren't formatted) .replace(/`([^`]+)`/g, '$1') // Bold + Italic .replace(/\*\*\*([^*]+)\*\*\*/g, '$1') .replace(/___([^_]+)___/g, '$1') // Bold .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/__([^_]+)__/g, '$1') // Italic .replace(/\*([^*]+)\*/g, '$1') .replace(/\b_([^_]+)_\b/g, '$1') // Strikethrough .replace(/~~([^~]+)~~/g, '$1'); } /** * Convert basic markdown text to safe HTML for agent logs/thinking */ export function renderMarkdownToHtml(text: string): string { if (!text) return ''; // Clean up internal agent XML-like tags (e.g. , , etc.) const cleanedText = text .replace(/<\/?(thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)[^\n>]*>?/gi, '') .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); // Escape HTML first to prevent XSS const escaped = cleanedText .replace(/&/g, '&') .replace(//g, '>'); const rawLines = escaped.split('\n'); const result: string[] = []; let currentListType: 'ul' | 'ol' | null = null; let inCodeBlock = false; let codeBlockLang = ''; let codeBlockLines: string[] = []; const closeList = () => { if (currentListType) { result.push(``); currentListType = null; } }; for (let i = 0; i < rawLines.length; i++) { const rawLine = rawLines[i]; const trimmed = rawLine.trim(); // 1. Code blocks: ```lang ... ``` if (trimmed.startsWith('```')) { if (inCodeBlock) { // End code block const codeContent = codeBlockLines.join('\n'); result.push(`
${codeContent}
`); inCodeBlock = false; codeBlockLines = []; codeBlockLang = ''; } else { closeList(); inCodeBlock = true; codeBlockLang = trimmed.substring(3).trim(); codeBlockLines = []; } continue; } if (inCodeBlock) { codeBlockLines.push(rawLine); continue; } // Markdown result tables need real cells to remain readable in reports. if (trimmed.includes('|') && i + 1 < rawLines.length && /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) { closeList(); const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|'); const headings = cells(rawLine); result.push('
' + headings.map(cell => ``).join('') + ''); i += 2; while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) { const row = cells(rawLines[i]); result.push('' + headings.map((_, index) => ``).join('') + ''); i++; } result.push('
${formatInlineMarkdown(cell.trim())}
${formatInlineMarkdown((row[index] || '').trim())}
'); i--; continue; } // 2. Headings: # to ###### const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { closeList(); const level = headingMatch[1].length; const headingContent = formatInlineMarkdown(headingMatch[2]); result.push(`${headingContent}`); continue; } // 3. Horizontal Rule: ---, ***, ___ if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { closeList(); result.push('
'); continue; } // 4. Blockquotes: > quote if (trimmed.startsWith('> ') || trimmed.startsWith('>')) { closeList(); const quoteContent = formatInlineMarkdown(trimmed.replace(/^>\s?/, '')); result.push(`
${quoteContent}
`); continue; } // 5. Unordered List: - item or * item const ulMatch = rawLine.match(/^(\s*)([\*\-])\s+(.*)$/); if (ulMatch) { if (currentListType !== 'ul') { closeList(); currentListType = 'ul'; result.push('