feat(app-expo): render chat bubbles as Markdown with correct bold

- Add ChatBubbleMarkdown using react-native-markdown-display; disable images
  in bubbles for safety.
- Strip fontWeight from leaf text styles so **strong** is not overridden by
  chatBubbleTextStyle (nested Text on RN).
- Wire MessageBubble and StreamingBubbles; keep streaming cursor appended
  to markdown string.
This commit is contained in:
Kevin
2026-03-27 17:37:07 +08:00
parent e4bf0710c7
commit 0d999cb769
2 changed files with 373 additions and 46 deletions

View File

@@ -59,6 +59,7 @@ import {
splitMessageParts,
splitStreamingSegments,
} from '@/features/conversation/message-split';
import { ChatBubbleMarkdown } from '@/features/conversation/chat-bubble-markdown';
import type { MessageItem } from '@/features/conversation/types';
import { isVoiceMessage } from '@/features/conversation/types';
import type { PlaybackItem } from '@/features/voice/types';
@@ -196,12 +197,11 @@ function MessageBubble({
isAssistantTtsHighlight && styles.bubbleAgentTtsActive,
]}
>
<Text
selectable
style={[styles.bubbleText, styles.bubbleTextAgent, bubbleTextStyle]}
>
{item.content}
</Text>
<ChatBubbleMarkdown
markdown={item.content}
variant="assistant"
textStyle={bubbleTextStyle}
/>
{isAssistantTextFirstPart &&
(ttsUrls.length > 0 || isThisBubbleTtsTarget) ? (
<View style={styles.readAloudRow}>
@@ -280,16 +280,11 @@ function MessageBubble({
</View>
) : isUser ? (
<View style={[styles.bubble, styles.bubbleUser]}>
<Text
selectable
style={[
styles.bubbleText,
styles.bubbleTextUser,
bubbleTextStyle,
]}
>
{item.content}
</Text>
<ChatBubbleMarkdown
markdown={item.content}
variant="user"
textStyle={bubbleTextStyle}
/>
</View>
) : (
<View style={styles.assistantBubbleWrap}>
@@ -460,6 +455,7 @@ function StreamingBubbles({
: [];
const streamingPart =
segments.length > 0 ? segments[segments.length - 1]! : streamingText;
const streamingWithCursor = streamingPart + (!isComplete ? '▌' : '');
const inner = (
<>
@@ -485,16 +481,11 @@ function StreamingBubbles({
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<Text
selectable
style={[
styles.bubbleText,
styles.bubbleTextAgent,
bubbleTextStyle,
]}
>
{part}
</Text>
<ChatBubbleMarkdown
markdown={part}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</View>
</View>
</View>
@@ -519,16 +510,11 @@ function StreamingBubbles({
{ opacity: streamPulse },
]}
>
<Text
selectable
style={[
styles.bubbleText,
styles.bubbleTextAgent,
bubbleTextStyle,
]}
>
{streamingPart}
</Text>
<ChatBubbleMarkdown
markdown={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</Animated.View>
) : (
<View
@@ -538,16 +524,11 @@ function StreamingBubbles({
streamingTtsActive && styles.bubbleAgentTtsActive,
]}
>
<Text
selectable
style={[
styles.bubbleText,
styles.bubbleTextAgent,
bubbleTextStyle,
]}
>
{streamingPart}
</Text>
<ChatBubbleMarkdown
markdown={streamingWithCursor}
variant="assistant"
textStyle={bubbleTextStyle}
/>
</View>
)}
</View>

View File

@@ -0,0 +1,346 @@
/**
* 聊天气泡内 Markdown与回忆录正文共用 react-native-markdown-display样式为紧凑气泡。
* 不渲染图片,避免聊天里加载任意 URL。
*/
import Markdown, { renderRules } from 'react-native-markdown-display';
import React, { useMemo } from 'react';
import { Linking, StyleSheet, Text, TextStyle, View } from 'react-native';
const COLORS = {
onSurface: '#1b1b1f',
onPrimary: '#ffffff',
primary: '#8177A6',
onSurfaceVariant: '#8D8C90',
};
const BASE_FONT = 17;
const BASE_LINE = 24;
/** 叶子 `text` 节点若带 fontWeight会盖住外层 `strong` 的粗体RN 嵌套 Text 规则)。字号/行高仍从设置传入。 */
function textStyleWithoutFontWeight(
style: TextStyle | undefined,
): TextStyle | undefined {
if (!style) return undefined;
const flat = StyleSheet.flatten(style) as TextStyle & {
fontWeight?: unknown;
};
const { fontWeight: _w, ...rest } = flat;
return rest;
}
export function ChatBubbleMarkdown({
markdown,
variant,
textStyle,
}: {
markdown: string;
variant: 'user' | 'assistant';
textStyle?: TextStyle;
}) {
const primaryColor = variant === 'user' ? COLORS.onPrimary : COLORS.onSurface;
const linkColor =
variant === 'user' ? 'rgba(255,255,255,0.92)' : COLORS.primary;
const muted =
variant === 'user' ? 'rgba(255,255,255,0.85)' : COLORS.onSurfaceVariant;
const codeBg =
variant === 'user' ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.06)';
const textStyleLeaf = useMemo(
() => textStyleWithoutFontWeight(textStyle),
[textStyle],
);
const styles = useMemo(
() =>
StyleSheet.create({
body: {
color: primaryColor,
fontSize: BASE_FONT,
lineHeight: BASE_LINE,
fontWeight: '400',
...textStyle,
},
paragraph: {
marginTop: 0,
marginBottom: 8,
color: primaryColor,
fontSize: BASE_FONT,
lineHeight: BASE_LINE,
fontWeight: '400',
...textStyle,
},
textgroup: {
color: primaryColor,
fontSize: BASE_FONT,
lineHeight: BASE_LINE,
...textStyle,
},
text: {
color: primaryColor,
fontSize: BASE_FONT,
lineHeight: BASE_LINE,
...textStyleLeaf,
},
strong: {
fontWeight: '700',
color: primaryColor,
},
em: {
fontStyle: 'italic',
color: primaryColor,
},
s: {
textDecorationLine: 'line-through',
color: primaryColor,
},
link: {
color: linkColor,
textDecorationLine: 'underline',
},
bullet_list: {
marginBottom: 0,
},
ordered_list: {
marginBottom: 0,
},
list_item: {
marginBottom: 4,
flexDirection: 'row',
justifyContent: 'flex-start',
},
bullet_list_icon: {
marginLeft: 0,
marginRight: 8,
color: muted,
},
code_inline: {
backgroundColor: codeBg,
paddingHorizontal: 4,
borderRadius: 4,
fontSize: BASE_FONT - 1,
color: primaryColor,
},
fence: {
backgroundColor: codeBg,
padding: 8,
borderRadius: 8,
marginVertical: 4,
fontSize: BASE_FONT - 1,
color: primaryColor,
},
code_block: {
backgroundColor: codeBg,
padding: 8,
borderRadius: 8,
marginVertical: 4,
fontSize: BASE_FONT - 1,
color: primaryColor,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor:
variant === 'user'
? 'rgba(255,255,255,0.45)'
: 'rgba(129,119,166,0.45)',
paddingLeft: 10,
marginVertical: 4,
color: muted,
},
heading1: {
fontSize: BASE_FONT + 2,
fontWeight: '700',
marginBottom: 6,
marginTop: 2,
color: primaryColor,
},
heading2: {
fontSize: BASE_FONT + 1,
fontWeight: '700',
marginBottom: 4,
marginTop: 2,
color: primaryColor,
},
heading3: {
fontSize: BASE_FONT,
fontWeight: '700',
marginBottom: 4,
marginTop: 2,
color: primaryColor,
},
hr: {
backgroundColor: muted,
height: StyleSheet.hairlineWidth,
marginVertical: 8,
},
}),
[primaryColor, linkColor, muted, codeBg, variant, textStyle, textStyleLeaf],
);
const rules = useMemo(
() => ({
...renderRules,
image: () => null,
text: (
node: { key: string; content: string },
_c: unknown,
_p: unknown,
styles: { text: object },
inheritedStyles: Record<string, unknown> = {},
) => (
<Text key={node.key} selectable style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: { key: string },
children: React.ReactNode,
_p: unknown,
styles: { textgroup: object },
) => (
<Text key={node.key} selectable style={styles.textgroup}>
{children}
</Text>
),
strong: (
node: { key: string },
children: React.ReactNode,
_p: unknown,
styles: { strong: object },
) => (
<Text key={node.key} selectable style={styles.strong}>
{children}
</Text>
),
em: (
node: { key: string },
children: React.ReactNode,
_p: unknown,
styles: { em: object },
) => (
<Text key={node.key} selectable style={styles.em}>
{children}
</Text>
),
s: (
node: { key: string },
children: React.ReactNode,
_p: unknown,
styles: { s: object },
) => (
<Text key={node.key} selectable style={styles.s}>
{children}
</Text>
),
link: (
node: { key: string; attributes: { href?: string } },
children: React.ReactNode,
_p: unknown,
styles: { link: object },
onLinkPress?: (url: string) => boolean,
) => (
<Text
key={node.key}
selectable
style={styles.link}
onPress={() => {
const url = node.attributes.href;
if (onLinkPress) {
const result = onLinkPress(url as string);
if (url && result && typeof result === 'boolean') {
void Linking.openURL(url);
}
} else if (url) {
void Linking.openURL(url);
}
}}
>
{children}
</Text>
),
code_inline: (
node: { key: string; content: string },
_c: unknown,
_p: unknown,
styles: { code_inline: object },
inheritedStyles: Record<string, unknown> = {},
) => (
<Text
key={node.key}
selectable
style={[inheritedStyles, styles.code_inline]}
>
{node.content}
</Text>
),
code_block: (
node: { key: string; content?: string },
_c: unknown,
_p: unknown,
styles: { code_block: object },
inheritedStyles: Record<string, unknown> = {},
) => {
let content = node.content;
if (
typeof node.content === 'string' &&
node.content.charAt(node.content.length - 1) === '\n'
) {
content = node.content.substring(0, node.content.length - 1);
}
return (
<Text
key={node.key}
selectable
style={[inheritedStyles, styles.code_block]}
>
{content}
</Text>
);
},
fence: (
node: { key: string; content?: string },
_c: unknown,
_p: unknown,
styles: { fence: object },
inheritedStyles: Record<string, unknown> = {},
) => {
let content = node.content;
if (
typeof node.content === 'string' &&
node.content.charAt(node.content.length - 1) === '\n'
) {
content = node.content.substring(0, node.content.length - 1);
}
return (
<Text
key={node.key}
selectable
style={[inheritedStyles, styles.fence]}
>
{content}
</Text>
);
},
}),
[],
);
const trimmed = (markdown ?? '').trim();
if (!trimmed) {
return null;
}
return (
<View style={stylesMarkdown.wrap}>
<Markdown style={styles} rules={rules} mergeStyle>
{trimmed}
</Markdown>
</View>
);
}
const stylesMarkdown = StyleSheet.create({
wrap: {
alignSelf: 'stretch',
},
});