feat: 流式Markdown渲染 - 行缓冲+ANSI样式输出

- MarkdownRenderer新增StreamState和renderStreamingLine()方法
- 流式输出改为行缓冲模式:累积token到换行再渲染输出
- 支持:粗体/斜体→ANSI样式, 代码块→语法高亮边框,
  列表→●圆点, 标题→彩色, 引用→竖线, 链接→下划线
- 代码块状态跨行追踪(StreamState)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
liuzh 1 month ago
parent 2e8e1eb819
commit b2066c0cc7
  1. 89
      src/main/java/com/claudecode/console/MarkdownRenderer.java
  2. 28
      src/main/java/com/claudecode/repl/ReplSession.java

@ -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(); // 流式输出结束后换行

Loading…
Cancel
Save