终端高亮与卡片渲染:为 AI 诊断结果实现轻量级 Markdown 语法着色

终端高亮与卡片渲染:为 AI 诊断结果实现轻量级 Markdown 语法着色

封面信息图

在昨天的第一周大联调中,我们成功跑通了 AI 流式诊断结果在终端 TUI 界面中的实时输出。然而,大语言模型生成的回复通常包含丰富的 Markdown 标记语言——如二级标题 ##、加粗 **排查建议**、行内代码 `iptables -L` 以及三反引号的代码块。

如果直接把这些原始 Markdown 文本原封不动地塞进 ratatui 的普通段落中,界面上充斥着各种星号和反引号,阅读体验非常杂乱。

为了让终端拥有像现代代码编辑器一样的专业视觉表现,今天我们在 packet-tui 模块中实现一个轻量、高效、流式兼容的终端 Markdown 语法高亮渲染器


1. 终端着色原理与 ANSI 样式映射

ratatui 提供了强大的样式抽象 Style 和富文本结构 Line / Span

  • 标题(# , ## :映射为黄色粗体(Color::Yellow + Modifier::BOLD),并带有下划线;
  • 加粗文本(**text**:映射为青色高亮(Color::Cyan + Modifier::BOLD);
  • 行内代码与代码块(code:映射为灰色背景与绿色文字(bg: Color::DarkGray, fg: Color::Green);
  • 列表项(- , 1. :映射为洋红色项目符号。
[ 原始大模型 Markdown 文本 ]
               │
               ▼ (轻量零拷贝状态机词法切分 Tokenizer)
[ 结构化 Styled Spans 数组 (Span::styled("建议 1", Style::...)) ]
               │
               ▼
[ Ratatui Paragraph Widget 渲染为彩色终端卡片 ]

2. 编写轻量终端 Markdown 解析器

crates/packet-tui/src/markdown.rs 中:

// crates/packet-tui/src/markdown.rs
use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

pub struct TerminalMarkdownRenderer;

impl TerminalMarkdownRenderer {
    /// 将包含 Markdown 语法的多行字符串转换为 Ratatui 的 Line 数组
    pub fn parse_to_lines(input: &str) -> Vec<Line<'static>> {
        let mut lines = Vec::new();
        let mut in_code_block = false;

        for raw_line in input.lines() {
            let trimmed = raw_line.trim();

            // 1. 处理代码块围栏 (```)
            if trimmed.starts_with("```") {
                in_code_block = !in_code_block;
                let fence_style = Style::default().fg(Color::DarkGray);
                lines.push(Line::from(vec![Span::styled(raw_line.to_string(), fence_style)]));
                continue;
            }

            // 2. 代码块内部的行高亮
            if in_code_block {
                let code_style = Style::default().fg(Color::Green).bg(Color::Rgb(30, 30, 30));
                lines.push(Line::from(vec![Span::styled(format!("  {}", raw_line), code_style)]));
                continue;
            }

            // 3. 处理标题
            if let Some(heading) = trimmed.strip_prefix("## ") {
                let h2_style = Style::default()
                    .fg(Color::Yellow)
                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
                lines.push(Line::from(vec![Span::styled(format!("▶ {}", heading), h2_style)]));
                continue;
            } else if let Some(heading) = trimmed.strip_prefix("# ") {
                let h1_style = Style::default()
                    .fg(Color::Magenta)
                    .add_modifier(Modifier::BOLD);
                lines.push(Line::from(vec![Span::styled(format!("■ {}", heading), h1_style)]));
                continue;
            }

            // 4. 处理无序列表
            if let Some(item) = trimmed.strip_prefix("- ") {
                let mut spans = vec![Span::styled(" • ", Style::default().fg(Color::Cyan))];
                spans.extend(Self::parse_inline_styles(item));
                lines.push(Line::from(spans));
                continue;
            }

            // 5. 普通文本行(解析行内加粗和行内代码)
            if trimmed.is_empty() {
                lines.push(Line::from(""));
            } else {
                let spans = Self::parse_inline_styles(raw_line);
                lines.push(Line::from(spans));
            }
        }

        lines
    }

    /// 解析行内的 **加粗** 和 `行内代码`
    fn parse_inline_styles(text: &str) -> Vec<Span<'static>> {
        let mut spans = Vec::new();
        let mut chars = text.chars().peekable();
        let mut current_buf = String::new();

        while let Some(ch) = chars.next() {
            if ch == '*' && chars.peek() == Some(&'*') {
                chars.next(); // 消费第二个 *
                if !current_buf.is_empty() {
                    spans.push(Span::raw(std::mem::take(&mut current_buf)));
                }

                // 提取加粗内容
                let mut bold_content = String::new();
                while let Some(b_ch) = chars.next() {
                    if b_ch == '*' && chars.peek() == Some(&'*') {
                        chars.next(); // 消费闭合的第二个 *
                        break;
                    }
                    bold_content.push(b_ch);
                }

                let bold_style = Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD);
                spans.push(Span::styled(bold_content, bold_style));
            } else if ch == '`' {
                if !current_buf.is_empty() {
                    spans.push(Span::raw(std::mem::take(&mut current_buf)));
                }

                // 提取行内代码
                let mut code_content = String::new();
                for c_ch in chars.by_ref() {
                    if c_ch == '`' {
                        break;
                    }
                    code_content.push(c_ch);
                }

                let inline_code_style = Style::default()
                    .fg(Color::LightGreen)
                    .bg(Color::Rgb(40, 40, 40));
                spans.push(Span::styled(format!(" {} ", code_content), inline_code_style));
            } else {
                current_buf.push(ch);
            }
        }

        if !current_buf.is_empty() {
            spans.push(Span::raw(current_buf));
        }

        spans
    }
}

3. 在 TUI 诊断面板中集成高亮渲染

crates/packet-tui/src/ui.rs 中替换原有的纯文本渲染:

// crates/packet-tui/src/ui.rs
use crate::app::TuiAppState;
use crate::markdown::TerminalMarkdownRenderer;
use ratatui::{
    layout::Rect,
    style::{Color, Style},
    widgets::{Block, Borders, Paragraph, Wrap},
    Frame,
};

pub fn render_ai_panel(frame: &mut Frame, area: Rect, state: &TuiAppState) {
    let ai_title = if state.is_ai_analyzing {
        " AI 诊断专家 ( 正在实时分析流...) "
    } else {
        " AI 诊断结论与处置建议 (Markdown 渲染) "
    };

    // 关键改造:将大模型原始 Markdown 转译为高亮 Line 序列!
    let lines = TerminalMarkdownRenderer::parse_to_lines(&state.ai_diagnosis_text);

    let paragraph = Paragraph::new(lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(ai_title)
                .border_style(Style::default().fg(Color::Cyan)),
        )
        .wrap(Wrap { trim: true });

    frame.render_widget(paragraph, area);
}

4. 实测效果

当 DeepSeek 针对一次 SYN Flood 异常返回如下诊断时:

## 异常定性
检测到来自同一网段的高频 **SYN 报文突发**。

## 处置建议
- 检查目标主机防火墙配置:`iptables -A INPUT -p tcp --syn -m limit --limit 1/s -j ACCEPT`
- 查看系统当前半连接队列占用情况。

终端界面上:

  • ▶ 异常定性 显示为黄色下划线粗体;
  • SYN 报文突发 显示为亮青色粗体;
  • iptables 命令被包裹在精致的深灰色代码框中,带浅绿色文字高亮。

总结

为 CLI 工具引入轻量 Markdown 渲染:

  • 零笨重第三方 AST 解析依赖,纯状态机单次扫描,耗时小于 0.1ms;
  • 将大模型的流式纯文本转换为专业级视觉看板,极大提升了排障体验。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值