feat: improve chat readability with markdown
This commit is contained in:
256
android/app/src/main/java/com/hyzq/boss/BossMarkdown.java
Normal file
256
android/app/src/main/java/com/hyzq/boss/BossMarkdown.java
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("代码"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user