feat: improve chat readability with markdown

This commit is contained in:
kris
2026-04-04 03:10:16 +08:00
parent 5ebb37cbfc
commit 829005ba66
4 changed files with 365 additions and 2 deletions

View File

@@ -0,0 +1,256 @@
package com.hyzq.boss;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.QuoteSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class BossMarkdown {
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private BossMarkdown() {}
public static CharSequence render(Context context, String markdown, boolean outgoing) {
if (TextUtils.isEmpty(markdown) || TextUtils.isEmpty(markdown.trim())) {
return "(空消息)";
}
Palette palette = Palette.resolve(context, outgoing);
SpannableStringBuilder builder = new SpannableStringBuilder();
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\n", -1);
boolean inCodeFence = false;
List<String> codeLines = new ArrayList<>();
for (String line : lines) {
String trimmed = line.trim();
if (trimmed.startsWith("```")) {
if (inCodeFence) {
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
codeLines.clear();
}
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
codeLines.add(line);
continue;
}
if (trimmed.isEmpty()) {
appendBlankLine(builder);
continue;
}
Matcher headingMatcher = HEADING_PATTERN.matcher(line);
if (headingMatcher.matches()) {
int level = headingMatcher.group(1).length();
appendHeading(builder, headingMatcher.group(2), level, palette);
continue;
}
Matcher bulletMatcher = BULLET_PATTERN.matcher(line);
if (bulletMatcher.matches()) {
appendBullet(builder, bulletMatcher.group(1), palette);
continue;
}
Matcher orderedMatcher = ORDERED_PATTERN.matcher(line);
if (orderedMatcher.matches()) {
appendOrdered(builder, orderedMatcher.group(1), orderedMatcher.group(2), palette);
continue;
}
if (trimmed.startsWith(">")) {
appendQuote(builder, trimmed.substring(1).trim(), palette);
continue;
}
appendParagraph(builder, line, palette);
}
if (inCodeFence && !codeLines.isEmpty()) {
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
}
trimTrailingNewline(builder);
return builder;
}
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
ensureBlockSeparation(builder, true);
int start = builder.length();
appendInlineStyled(builder, text, palette);
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
float size = level == 1 ? 1.18f : level == 2 ? 1.1f : 1.04f;
builder.setSpan(new RelativeSizeSpan(size), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendParagraph(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
appendInlineStyled(builder, text, palette);
builder.append('\n');
}
private static void appendBullet(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
builder.append("");
appendInlineStyled(builder, text, palette);
builder.setSpan(new BulletSpan(BossUi.dp(palette.context, 6), palette.quoteColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendOrdered(SpannableStringBuilder builder, String number, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
builder.append(number).append(". ");
appendInlineStyled(builder, text, palette);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendQuote(SpannableStringBuilder builder, String text, Palette palette) {
ensureBlockSeparation(builder, false);
int start = builder.length();
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) {
if (TextUtils.isEmpty(text)) {
return;
}
ensureBlockSeparation(builder, true);
int start = builder.length();
builder.append(text);
int end = builder.length();
builder.setSpan(new TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 10), BossUi.dp(palette.context, 10)),
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}
private static void appendInlineStyled(SpannableStringBuilder builder, String text, Palette palette) {
Matcher matcher = INLINE_TOKEN_PATTERN.matcher(text);
int cursor = 0;
while (matcher.find()) {
if (matcher.start() > cursor) {
builder.append(text, cursor, matcher.start());
}
if (matcher.group(2) != null) {
int start = builder.length();
builder.append(matcher.group(2));
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} else if (matcher.group(4) != null) {
int start = builder.length();
builder.append(matcher.group(4));
builder.setSpan(new TypefaceSpan("monospace"), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
cursor = matcher.end();
}
if (cursor < text.length()) {
builder.append(text.substring(cursor));
}
}
private static void ensureBlockSeparation(SpannableStringBuilder builder, boolean relaxed) {
if (builder.length() == 0) {
return;
}
if (builder.charAt(builder.length() - 1) != '\n') {
builder.append('\n');
return;
}
if (!relaxed) {
return;
}
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
builder.append('\n');
}
}
private static void appendBlankLine(SpannableStringBuilder builder) {
if (builder.length() == 0) {
return;
}
if (builder.charAt(builder.length() - 1) != '\n') {
builder.append('\n');
}
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
builder.append('\n');
}
}
private static void trimTrailingNewline(SpannableStringBuilder builder) {
while (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
builder.delete(builder.length() - 1, builder.length());
}
}
private static String joinCodeLines(List<String> codeLines) {
StringBuilder builder = new StringBuilder();
for (int index = 0; index < codeLines.size(); index += 1) {
if (index > 0) {
builder.append('\n');
}
builder.append(codeLines.get(index));
}
return builder.toString();
}
private static final class Palette {
final Context context;
final int quoteColor;
final int codeBackgroundColor;
final int codeTextColor;
private Palette(Context context, int quoteColor, int codeBackgroundColor, int codeTextColor) {
this.context = context;
this.quoteColor = quoteColor;
this.codeBackgroundColor = codeBackgroundColor;
this.codeTextColor = codeTextColor;
}
static Palette resolve(Context context, boolean outgoing) {
if (outgoing) {
return new Palette(
context,
Color.parseColor("#B7E6C6"),
Color.parseColor("#33FFFFFF"),
context.getColor(R.color.boss_surface)
);
}
return new Palette(
context,
Color.parseColor("#91A39A"),
Color.parseColor("#FFF0F1F3"),
context.getColor(R.color.boss_text_primary)
);
}
}
}

View File

@@ -9,6 +9,7 @@ import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.graphics.drawable.GradientDrawable;
import android.text.Layout;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.Gravity;
@@ -850,11 +851,13 @@ public final class BossUi {
}
TextView bodyView = new TextView(context);
bodyView.setText(TextUtils.isEmpty(body) ? "(空消息)" : body);
bodyView.setText(BossMarkdown.render(context, body, outgoing));
bodyView.setTextSize(15);
bodyView.setLineSpacing(0f, 1.2f);
bodyView.setLineSpacing(0f, 1.34f);
bodyView.setTextColor(context.getColor(outgoing ? R.color.boss_surface : R.color.boss_text_primary));
bodyView.setMaxWidth(maxBubbleWidth);
bodyView.setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY);
bodyView.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NORMAL);
bubble.addView(bodyView);
wrapper.addView(bubble);

View File

@@ -0,0 +1,63 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.QuoteSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossMarkdownTest {
@Test
public void render_formatsCommonMarkdownPatternsForChatReading() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(
context,
"# 标题\n\n" +
"- 第一项\n" +
"1. 第二项\n\n" +
"> 引用内容\n\n" +
"普通段落里有 **重点** 和 `代码`\n\n" +
"```js\nconst ok = true;\n```",
false
);
assertTrue(rendered instanceof Spanned);
Spanned spanned = (Spanned) rendered;
assertTrue(spanned.toString().contains("标题"));
assertTrue(spanned.toString().contains("• 第一项"));
assertTrue(spanned.toString().contains("1. 第二项"));
assertTrue(spanned.toString().contains("引用内容"));
assertTrue(spanned.toString().contains("重点"));
assertTrue(spanned.toString().contains("代码"));
assertTrue(spanned.toString().contains("const ok = true;"));
assertTrue(spanned.getSpans(0, spanned.length(), StyleSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), BulletSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), QuoteSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), TypefaceSpan.class).length > 0);
assertTrue(spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class).length > 0);
}
@Test
public void render_returnsReadablePlaceholderForEmptyBody() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(context, "", false);
assertEquals("(空消息)", rendered.toString());
}
}

View File

@@ -0,0 +1,41 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.text.Spanned;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiMessageBubbleTest {
@Test
public void buildMessageBubble_rendersMarkdownInsteadOfRawPlainText() {
Context context = RuntimeEnvironment.getApplication();
LinearLayout wrapper = BossUi.buildMessageBubble(
context,
"主 Agent",
"# 标题\n- 条目\n\n带 **重点** 和 `代码`",
"10:26",
false,
null
);
LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1);
TextView bodyView = (TextView) bubble.getChildAt(0);
assertTrue(bodyView.getText() instanceof Spanned);
assertTrue(bodyView.getText().toString().contains("标题"));
assertTrue(bodyView.getText().toString().contains("• 条目"));
assertTrue(bodyView.getText().toString().contains("重点"));
assertTrue(bodyView.getText().toString().contains("代码"));
}
}