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