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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,31 +329,47 @@ public class ReplSession {
|
||||
conversationSummary = input.length() > 40 ? input.substring(0, 40) : input;
|
||||
}
|
||||
|
||||
// Agent 循环(流式输出)
|
||||
// Agent 循环(流式输出 + 行缓冲 Markdown 渲染)
|
||||
try {
|
||||
spinner.start("Thinking...");
|
||||
streamNewLine = true; // spinner 停止后 onStreamStart 会打印 ● 前缀
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 流式回调:逐 token 输出到终端,自动在每行开头加缩进
|
||||
// 行缓冲:累积 token 直到换行,再用 MarkdownRenderer 渲染整行输出
|
||||
StringBuilder lineBuffer = new StringBuilder();
|
||||
MarkdownRenderer.StreamState mdState = new MarkdownRenderer.StreamState();
|
||||
|
||||
String response = agentLoop.runStreaming(input, token -> {
|
||||
for (int i = 0; i < token.length(); i++) {
|
||||
char c = token.charAt(i);
|
||||
if (c == '\n') {
|
||||
out.println();
|
||||
streamNewLine = true;
|
||||
} else {
|
||||
// 行完成 → 渲染 Markdown 并输出
|
||||
if (streamNewLine) {
|
||||
out.print(" "); // 续行缩进(与 ● 后文本对齐)
|
||||
streamNewLine = false;
|
||||
}
|
||||
out.print(c);
|
||||
String rendered = markdownRenderer.renderStreamingLine(lineBuffer.toString(), mdState);
|
||||
out.println(rendered);
|
||||
lineBuffer.setLength(0);
|
||||
streamNewLine = true;
|
||||
} else {
|
||||
lineBuffer.append(c);
|
||||
}
|
||||
}
|
||||
out.flush();
|
||||
});
|
||||
|
||||
// 刷新残留缓冲(最后一行可能无 \n 结尾)
|
||||
if (!lineBuffer.isEmpty()) {
|
||||
if (streamNewLine) {
|
||||
out.print(" ");
|
||||
streamNewLine = false;
|
||||
}
|
||||
String rendered = markdownRenderer.renderStreamingLine(lineBuffer.toString(), mdState);
|
||||
out.print(rendered);
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
out.println(); // 流式输出结束后换行
|
||||
|
||||
|
||||
Reference in New Issue
Block a user