feat: 流式Markdown渲染 - 行缓冲+ANSI样式输出
- MarkdownRenderer新增StreamState和renderStreamingLine()方法 - 流式输出改为行缓冲模式:累积token到换行再渲染输出 - 支持:粗体/斜体→ANSI样式, 代码块→语法高亮边框, 列表→●圆点, 标题→彩色, 引用→竖线, 链接→下划线 - 代码块状态跨行追踪(StreamState) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -267,4 +267,93 @@ public class MarkdownRenderer {
|
||||
text = text.replaceAll("\\[(.+?)]\\((.+?)\\)", AnsiStyle.UNDERLINE + "$1" + AnsiStyle.RESET + AnsiStyle.DIM + " ($2)" + AnsiStyle.RESET);
|
||||
return text;
|
||||
}
|
||||
|
||||
// ==================== 流式 Markdown 渲染 ====================
|
||||
|
||||
/**
|
||||
* 流式渲染状态 —— 跨行追踪代码块等多行结构。
|
||||
* 每次 REPL 对话轮次创建一个新实例。
|
||||
*/
|
||||
public static class StreamState {
|
||||
boolean inCodeBlock = false;
|
||||
String codeLang = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单行流式文本(无前缀缩进,由调用方处理)。
|
||||
* <p>
|
||||
* 支持:代码块(带语法高亮)、标题、列表、引用、行内格式(粗体/斜体/代码)。
|
||||
* 代码块状态通过 {@link StreamState} 跨行维护。
|
||||
*
|
||||
* @param line 一行完整文本(不含换行符)
|
||||
* @param state 跨行状态(代码块追踪)
|
||||
* @return 带 ANSI 样式的渲染结果
|
||||
*/
|
||||
public String renderStreamingLine(String line, StreamState state) {
|
||||
String stripped = line.stripLeading();
|
||||
|
||||
// 代码块边界
|
||||
if (stripped.startsWith("```")) {
|
||||
if (!state.inCodeBlock) {
|
||||
state.codeLang = stripped.substring(3).strip().toLowerCase();
|
||||
state.inCodeBlock = true;
|
||||
String langLabel = state.codeLang.isEmpty() ? "code" : state.codeLang;
|
||||
return AnsiStyle.dim("┌─" + langLabel + "─" + "─".repeat(Math.max(0, 40 - langLabel.length())));
|
||||
} else {
|
||||
state.inCodeBlock = false;
|
||||
state.codeLang = "";
|
||||
return AnsiStyle.dim("└" + "─".repeat(42));
|
||||
}
|
||||
}
|
||||
|
||||
// 代码块内容(语法高亮)
|
||||
if (state.inCodeBlock) {
|
||||
return AnsiStyle.DIM + "│" + AnsiStyle.RESET + " " + highlightCode(line, state.codeLang);
|
||||
}
|
||||
|
||||
// 标题
|
||||
if (stripped.startsWith("### ")) {
|
||||
return AnsiStyle.bold(AnsiStyle.CYAN + stripped.substring(4)) + AnsiStyle.RESET;
|
||||
} else if (stripped.startsWith("## ")) {
|
||||
return AnsiStyle.bold(AnsiStyle.BLUE + stripped.substring(3)) + AnsiStyle.RESET;
|
||||
} else if (stripped.startsWith("# ")) {
|
||||
return AnsiStyle.bold(AnsiStyle.MAGENTA + stripped.substring(2)) + AnsiStyle.RESET;
|
||||
}
|
||||
|
||||
// 引用块
|
||||
if (stripped.startsWith("> ")) {
|
||||
return AnsiStyle.DIM + "┃" + AnsiStyle.RESET + " " + AnsiStyle.ITALIC + renderInline(stripped.substring(2)) + AnsiStyle.RESET;
|
||||
}
|
||||
|
||||
// 复选框列表(在无序列表前检测)
|
||||
if (stripped.startsWith("- [ ] ")) {
|
||||
return AnsiStyle.DIM + "☐" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6));
|
||||
}
|
||||
if (stripped.startsWith("- [x] ") || stripped.startsWith("- [X] ")) {
|
||||
return AnsiStyle.GREEN + "☑" + AnsiStyle.RESET + " " + renderInline(stripped.substring(6));
|
||||
}
|
||||
|
||||
// 无序列表
|
||||
if (stripped.startsWith("- ") || stripped.startsWith("* ")) {
|
||||
int indent = line.length() - stripped.length();
|
||||
String prefix = " ".repeat(indent);
|
||||
return prefix + AnsiStyle.CYAN + "•" + AnsiStyle.RESET + " " + renderInline(stripped.substring(2));
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (stripped.matches("^\\d+\\.\\s+.*")) {
|
||||
Matcher m = Pattern.compile("^(\\s*)(\\d+)\\.\\s+(.*)").matcher(line);
|
||||
if (m.matches()) {
|
||||
return m.group(1) + AnsiStyle.CYAN + m.group(2) + "." + AnsiStyle.RESET + " " + renderInline(m.group(3));
|
||||
}
|
||||
}
|
||||
|
||||
// 分隔线
|
||||
if (stripped.matches("^[-*]{3,}$")) {
|
||||
return AnsiStyle.dim("─".repeat(42));
|
||||
}
|
||||
|
||||
// 普通文本(行内格式渲染)
|
||||
return renderInline(line);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user