终端高亮与卡片渲染:为 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;
- 将大模型的流式纯文本转换为专业级视觉看板,极大提升了排障体验。

495

被折叠的 条评论
为什么被折叠?



