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:
@@ -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>
|
||||
|
||||
346
app-expo/src/features/conversation/chat-bubble-markdown.tsx
Normal file
346
app-expo/src/features/conversation/chat-bubble-markdown.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user